From 9a7f23eb953371c0aa3cdb918ab74b1074b84e58 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 19 May 2026 17:57:36 -0500 Subject: [PATCH 01/46] CEXT-6160: store and expose Commerce system config during app association lifecycle --- .changeset/humble-walls-shout.md | 5 + packages/aio-commerce-lib-app/docs/usage.md | 41 +++ packages/aio-commerce-lib-app/package.json | 11 + .../source/actions/installation.ts | 96 ++++++- .../aio-commerce-lib-app/source/runtime.ts | 50 ++++ .../test/unit/actions/installation.test.ts | 161 ++++++++++- .../test/unit/runtime/index.test.ts | 72 +++++ .../aio-commerce-lib-app/tsdown.config.ts | 1 + ...0-commerce-system-config-on-association.md | 254 ++++++++++++++++++ 9 files changed, 689 insertions(+), 2 deletions(-) create mode 100644 .changeset/humble-walls-shout.md create mode 100644 packages/aio-commerce-lib-app/source/runtime.ts create mode 100644 packages/aio-commerce-lib-app/test/unit/runtime/index.test.ts create mode 100644 specs/features/CEXT-6160-commerce-system-config-on-association.md diff --git a/.changeset/humble-walls-shout.md b/.changeset/humble-walls-shout.md new file mode 100644 index 000000000..98f064f59 --- /dev/null +++ b/.changeset/humble-walls-shout.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-app": minor +--- + +Expose the Commerce system configuration (Base URL and deployment type) to runtime actions automatically when an app is associated with a Commerce instance, and clear it on unassociation. diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index c58df3eac..682a07d62 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -705,6 +705,47 @@ try { } ``` +## Runtime Helpers + +The `@adobe/aio-commerce-lib-app/runtime` export provides helpers for use inside runtime actions. + +### Commerce System Configuration + +When `commerce-app-management` associates an app with a Commerce instance, the SDK automatically +stores the Commerce system configuration. Use `getCommerceSystemConfig` to retrieve it from any +runtime action — no custom storage setup is required. + +```ts +import { getCommerceSystemConfig } from "@adobe/aio-commerce-lib-app/runtime"; + +export async function main(params) { + const config = getCommerceSystemConfig(params); + if (!config) { + // App is not currently associated with a Commerce instance. + return { + statusCode: 400, + body: { error: "Not associated with a Commerce instance" }, + }; + } + + // config.baseUrl — Commerce API base URL, e.g. "https://my-store.example.com" + // config.env — "saas" | "paas" +} +``` + +When `getCommerceSystemConfig` returns `null`, the app is either not currently associated with a +Commerce instance, or the association predates this feature. Apps must handle this case explicitly. + +The configuration is cleared automatically when the app is unassociated, so runtime actions always +reflect the current association state. + +#### Available fields + +| Field | Type | Description | +| --------- | ------------------ | ---------------------------------------- | +| `baseUrl` | `string` | Commerce API base URL | +| `env` | `"saas" \| "paas"` | Deployment type of the Commerce instance | + ## 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 580fb99c5..d3d9f1ab6 100644 --- a/packages/aio-commerce-lib-app/package.json +++ b/packages/aio-commerce-lib-app/package.json @@ -63,6 +63,16 @@ "default": "./dist/cjs/management/index.cjs" } }, + "./runtime": { + "import": { + "types": "./dist/es/runtime.d.mts", + "default": "./dist/es/runtime.mjs" + }, + "require": { + "types": "./dist/cjs/runtime.d.cts", + "default": "./dist/cjs/runtime.cjs" + } + }, "./package.json": "./package.json" } }, @@ -70,6 +80,7 @@ "./actions": "./source/actions/index.ts", "./config": "./source/config/index.ts", "./management": "./source/management/index.ts", + "./runtime": "./source/runtime.ts", "./package.json": "./package.json" }, "imports": { diff --git a/packages/aio-commerce-lib-app/source/actions/installation.ts b/packages/aio-commerce-lib-app/source/actions/installation.ts index 28dca54ec..a922e283c 100644 --- a/packages/aio-commerce-lib-app/source/actions/installation.ts +++ b/packages/aio-commerce-lib-app/source/actions/installation.ts @@ -24,7 +24,7 @@ import { } from "@aio-commerce-sdk/common-utils/actions"; import { createCombinedStore } from "@aio-commerce-sdk/common-utils/storage"; import openwhisk from "openwhisk"; -import { object, string } from "valibot"; +import { object, picklist, string } from "valibot"; import { createInitialInstallationState, @@ -49,6 +49,7 @@ import type { InProgressInstallationState, InstallationState, } from "#management/installation/workflow/types"; +import type { CommerceSystemConfig } from "#runtime"; // Action name for async invocation const DEFAULT_ACTION_NAME = "app-management/installation"; @@ -82,6 +83,12 @@ const InstallationRequestBodySchema = object({ ioEventsEnv: string(), }); +/** Request body schema for POST /association. */ +const AssociationRequestBodySchema = object({ + commerceBaseUrl: string(), + commerceEnv: picklist(["saas", "paas"]), +}); + type WorkflowRequestBody = { appData: InstallationContext["appData"]; commerceBaseUrl: string; @@ -111,6 +118,43 @@ function createUninstallationStore() { return createWorkflowStore("uninstallation"); } +const PARAM_COMMERCE_BASE_URL = "AIO_COMMERCE_BASE_URL"; +const PARAM_COMMERCE_ENV = "AIO_COMMERCE_ENV"; + +/** Extracts the OpenWhisk package name from an action name. */ +function getPackageName(actionName: string): string { + const parts = actionName.split("/").filter(Boolean); + if (parts.length < 2) { + throw new Error(`Cannot determine package from action name: ${actionName}`); + } + return parts.at(-2) as string; +} + +/** + * Merges the given key/value updates into the package's existing parameters, + * removing entries whose value is undefined. + */ +async function syncPackageParams( + packageName: string, + updates: Record, +): Promise { + const ow = openwhisk(); + const current = await ow.packages.get({ name: packageName }); + const existing = (current.parameters ?? []) as Array<{ + key: string; + value: string; + }>; + const updateKeys = Object.keys(updates); + const kept = existing.filter((p) => !updateKeys.includes(p.key)); + const added = updateKeys + .filter((key) => updates[key] !== undefined) + .map((key) => ({ key, value: updates[key] as string })); + await ow.packages.update({ + name: packageName, + package: { parameters: [...kept, ...added] }, + }); +} + /** Returns the storage key used to store the current installation ID. */ function getStorageKey() { // For simplicity, we use a single key to store the current installation state. @@ -600,6 +644,56 @@ router.delete("/uninstallation", { }, }); +/** + * POST /association - Store Commerce system config + * + * Called by commerce-app-management after associating the app with a Commerce instance. + * Stores the Base URL and env (PaaS/SaaS) for retrieval by runtime actions via + * getCommerceSystemConfig(). + */ +router.post("/association", { + body: AssociationRequestBodySchema, + + handler: async (req, { logger }) => { + const { commerceBaseUrl, commerceEnv } = req.body; + logger.debug( + `Storing Commerce system config: baseUrl=${commerceBaseUrl}, env=${commerceEnv}`, + ); + + const packageName = getPackageName(process.env.__OW_ACTION_NAME ?? ""); + await syncPackageParams(packageName, { + [PARAM_COMMERCE_BASE_URL]: commerceBaseUrl, + [PARAM_COMMERCE_ENV]: commerceEnv, + }); + + const config: CommerceSystemConfig = { + baseUrl: commerceBaseUrl, + env: commerceEnv, + }; + return ok({ body: config }); + }, +}); + +/** + * DELETE /association - Clear Commerce system config + * + * Called by commerce-app-management after unassociating the app from a Commerce instance. + * Removes the stored config so runtime actions no longer have access to stale data. + */ +router.delete("/association", { + handler: async (_req, { logger }) => { + logger.debug("Clearing Commerce system config"); + + const packageName = getPackageName(process.env.__OW_ACTION_NAME ?? ""); + await syncPackageParams(packageName, { + [PARAM_COMMERCE_BASE_URL]: undefined, + [PARAM_COMMERCE_ENV]: undefined, + }); + + return noContent(); + }, +}); + /** Factory to create the route handler for the `installation` action. */ export const installationRuntimeAction = ({ appConfig, customScriptsLoader }: RuntimeActionFactoryArgs) => diff --git a/packages/aio-commerce-lib-app/source/runtime.ts b/packages/aio-commerce-lib-app/source/runtime.ts new file mode 100644 index 000000000..2ba179517 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/runtime.ts @@ -0,0 +1,50 @@ +/* + * 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 { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; + +/** The Commerce system configuration populated during app association. */ +export type CommerceSystemConfig = { + /** The Commerce API base URL for the associated instance. */ + baseUrl: string; + /** The deployment type of the associated Commerce instance. */ + env: "saas" | "paas"; +}; + +/** + * Returns the Commerce system configuration populated when the app was associated + * with a Commerce instance, or `null` if the app is not currently associated. + * + * The values are available as the reserved package parameters `AIO_COMMERCE_BASE_URL` + * and `AIO_COMMERCE_ENV`, set automatically by the SDK during association. + * + * @example + * ```ts + * const config = getCommerceSystemConfig(params); + * if (!config) { + * return { statusCode: 400, body: { error: "Not associated with a Commerce instance" } }; + * } + * // config.baseUrl — Commerce API base URL + * // config.env — "saas" | "paas" + * ``` + */ +export function getCommerceSystemConfig( + params: RuntimeActionParams, +): CommerceSystemConfig | null { + const p = params as Record; + const baseUrl = p.AIO_COMMERCE_BASE_URL; + const env = p.AIO_COMMERCE_ENV; + if (typeof baseUrl !== "string" || typeof env !== "string") { + return null; + } + return { baseUrl, env: env as "saas" | "paas" }; +} diff --git a/packages/aio-commerce-lib-app/test/unit/actions/installation.test.ts b/packages/aio-commerce-lib-app/test/unit/actions/installation.test.ts index 85d53fc25..151b5e03f 100644 --- a/packages/aio-commerce-lib-app/test/unit/actions/installation.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/actions/installation.test.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const { invokeMock, @@ -21,8 +21,12 @@ const { runInstallationMock, runUninstallationMock, runValidationMock, + mockPackagesGet, + mockPackagesUpdate, } = vi.hoisted(() => { const invokeMock = vi.fn(); + const mockPackagesGet = vi.fn(); + const mockPackagesUpdate = vi.fn(); return { invokeMock, @@ -30,6 +34,10 @@ const { actions: { invoke: invokeMock, }, + packages: { + get: mockPackagesGet, + update: mockPackagesUpdate, + }, })), createCombinedStoreMock: vi.fn(), createInitialInstallationStateMock: vi.fn(), @@ -37,6 +45,8 @@ const { runInstallationMock: vi.fn(), runUninstallationMock: vi.fn(), runValidationMock: vi.fn(), + mockPackagesGet, + mockPackagesUpdate, }; }); @@ -784,4 +794,153 @@ describe("installationRuntimeAction", () => { }); }); }); + + describe("POST /association", () => { + beforeEach(() => { + process.env.__OW_ACTION_NAME = "/namespace/test-package/installation"; + mockPackagesGet.mockResolvedValue({ parameters: [] }); + mockPackagesUpdate.mockResolvedValue({}); + }); + + afterEach(() => { + delete process.env.__OW_ACTION_NAME; + }); + + test("stores config as package params and returns 200 with the stored config", async () => { + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/association", + body: { + commerceBaseUrl: "https://commerce.example.com", + commerceEnv: "saas", + }, + }), + ); + + expect(result).toMatchObject({ + type: "success", + statusCode: 200, + body: { baseUrl: "https://commerce.example.com", env: "saas" }, + }); + expect(mockPackagesUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + name: "test-package", + package: expect.objectContaining({ + parameters: expect.arrayContaining([ + { + key: "AIO_COMMERCE_BASE_URL", + value: "https://commerce.example.com", + }, + { key: "AIO_COMMERCE_ENV", value: "saas" }, + ]), + }), + }), + ); + }); + + test("accepts paas env", async () => { + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/association", + body: { + commerceBaseUrl: "https://commerce.example.com", + commerceEnv: "paas", + }, + }), + ); + + expect(result).toMatchObject({ + type: "success", + statusCode: 200, + body: { env: "paas" }, + }); + }); + + test("returns 400 when commerceBaseUrl is missing", async () => { + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/association", + body: { commerceEnv: "saas" }, + }), + ); + + expect(result).toMatchObject({ error: { statusCode: 400 } }); + }); + + test("returns 400 when commerceEnv is invalid", async () => { + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ + method: "post", + path: "/association", + body: { + commerceBaseUrl: "https://commerce.example.com", + commerceEnv: "invalid-env", + }, + }), + ); + + expect(result).toMatchObject({ error: { statusCode: 400 } }); + }); + }); + + describe("DELETE /association", () => { + beforeEach(() => { + process.env.__OW_ACTION_NAME = "/namespace/test-package/installation"; + mockPackagesUpdate.mockResolvedValue({}); + }); + + afterEach(() => { + delete process.env.__OW_ACTION_NAME; + }); + + test("removes package params and returns 204", async () => { + mockPackagesGet.mockResolvedValue({ + parameters: [ + { + key: "AIO_COMMERCE_BASE_URL", + value: "https://commerce.example.com", + }, + { key: "AIO_COMMERCE_ENV", value: "saas" }, + { key: "OTHER_PARAM", value: "keep-me" }, + ], + }); + + const handler = installationRuntimeAction({ + appConfig: minimalValidConfig, + }); + + const result = await handler( + createRuntimeActionParams({ method: "delete", path: "/association" }), + ); + + expect(result).toMatchObject({ type: "success", statusCode: 204 }); + expect(mockPackagesUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + name: "test-package", + package: expect.objectContaining({ + parameters: [{ key: "OTHER_PARAM", value: "keep-me" }], + }), + }), + ); + }); + }); }); diff --git a/packages/aio-commerce-lib-app/test/unit/runtime/index.test.ts b/packages/aio-commerce-lib-app/test/unit/runtime/index.test.ts new file mode 100644 index 000000000..7a672cdb0 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/runtime/index.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { describe, expect, test } from "vitest"; + +import { getCommerceSystemConfig } from "#runtime"; + +import type { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; + +function makeParams( + overrides: Record = {}, +): RuntimeActionParams { + return overrides as unknown as RuntimeActionParams; +} + +describe("getCommerceSystemConfig", () => { + test("returns the stored config when both params are present", () => { + const result = getCommerceSystemConfig( + makeParams({ + AIO_COMMERCE_BASE_URL: "https://commerce.example.com", + AIO_COMMERCE_ENV: "saas", + }), + ); + + expect(result).toEqual({ + baseUrl: "https://commerce.example.com", + env: "saas", + }); + }); + + test("returns config for paas env", () => { + const result = getCommerceSystemConfig( + makeParams({ + AIO_COMMERCE_BASE_URL: "https://commerce.example.com", + AIO_COMMERCE_ENV: "paas", + }), + ); + + expect(result).toEqual({ + baseUrl: "https://commerce.example.com", + env: "paas", + }); + }); + + test("returns null when AIO_COMMERCE_BASE_URL is absent", () => { + const result = getCommerceSystemConfig( + makeParams({ AIO_COMMERCE_ENV: "saas" }), + ); + expect(result).toBeNull(); + }); + + test("returns null when AIO_COMMERCE_ENV is absent", () => { + const result = getCommerceSystemConfig( + makeParams({ AIO_COMMERCE_BASE_URL: "https://commerce.example.com" }), + ); + expect(result).toBeNull(); + }); + + test("returns null when params are empty", () => { + const result = getCommerceSystemConfig(makeParams()); + expect(result).toBeNull(); + }); +}); diff --git a/packages/aio-commerce-lib-app/tsdown.config.ts b/packages/aio-commerce-lib-app/tsdown.config.ts index 6006fc4b6..bfe3b5b29 100644 --- a/packages/aio-commerce-lib-app/tsdown.config.ts +++ b/packages/aio-commerce-lib-app/tsdown.config.ts @@ -24,6 +24,7 @@ export default mergeConfig(baseConfig, { "./source/config/index.ts", "./source/commands/index.ts", "./source/management/index.ts", + "./source/runtime.ts", ], define: { __PKG_VERSION__: JSON.stringify(pkg.version), diff --git a/specs/features/CEXT-6160-commerce-system-config-on-association.md b/specs/features/CEXT-6160-commerce-system-config-on-association.md new file mode 100644 index 000000000..37f1ff52f --- /dev/null +++ b/specs/features/CEXT-6160-commerce-system-config-on-association.md @@ -0,0 +1,254 @@ +# Commerce System Configuration on Association + +- **Ticket:** [CEXT-6160](https://jira.corp.adobe.com/browse/CEXT-6160) +- **Created:** 2026-05-14 +- [x] **Implemented** + +## Summary + +Store the Commerce system configuration — Base URL and deployment type (PaaS or SaaS) — as +reserved OpenWhisk package parameters when an app is associated with a Commerce instance, so that +runtime actions receive them automatically without each app reimplementing the same storage logic. +Clear the parameters when the app is unassociated. + +## Motivation + +Most Commerce apps need to call the Commerce API from their runtime actions. To do so, they need +the Base URL of the instance they are associated with and the deployment type (PaaS or SaaS). +Today, each app reimplements this logic independently: the B2B Approval Demo app, for example, +stores the Base URL in Business Configuration during a custom installation step. + +This pattern has two problems: + +1. **Duplication.** Every app that needs to call the Commerce API writes the same storage and + retrieval logic. +2. **Wrong lifecycle.** Storing the config during installation does not keep it in sync with the + association state. If the app is associated with a different instance, or unassociated, the + stored config is not updated or cleared. + +The SDK is already passed `commerceBaseUrl` and `commerceEnv` at every association and +unassociation event — the data apps need is already flowing through the system. The missing piece +is persistence tied to the association lifecycle and a standard way for runtime actions to consume +it. + +**Goals:** + +- Store the Commerce Base URL and deployment type (PaaS/SaaS) as OpenWhisk package parameters + when an app is associated with a Commerce instance. +- Clear the stored parameters when the app is unassociated. +- Expose a typed helper that runtime actions can use to read the values from their params. + +**Non-goals:** + +- Storing any Commerce system configuration beyond Base URL and deployment type. +- Replacing the `AIO_COMMERCE_API_BASE_URL` and `AIO_COMMERCE_API_FLAVOR` package params set + during installation. Those remain unchanged. +- Providing a mechanism for apps to override or augment the stored config. + +## Developer experience + +After this feature ships, the Commerce system configuration is available directly in the action's +`params` object under two reserved names: + +| Reserved parameter | Description | +| ----------------------- | ------------------------------------------------ | +| `AIO_COMMERCE_BASE_URL` | Commerce API base URL of the associated instance | +| `AIO_COMMERCE_ENV` | Deployment type: `"saas"` or `"paas"` | + +A runtime action can read these directly: + +```js +export async function main(params) { + const baseUrl = params.AIO_COMMERCE_BASE_URL; + const env = params.AIO_COMMERCE_ENV; +} +``` + +Or use the typed helper from the `./runtime` export: + +```ts +import { getCommerceSystemConfig } from "@adobe/aio-commerce-lib-app/runtime"; + +export async function main(params) { + const config = getCommerceSystemConfig(params); + if (!config) { + // App is not associated with a Commerce instance + return { + statusCode: 400, + body: { error: "Not associated with a Commerce instance" }, + }; + } + + // config.baseUrl — the Commerce Base URL, e.g. "https://my-store.example.com" + // config.env — "saas" | "paas" +} +``` + +No custom storage setup is required. The SDK manages the parameters transparently during the +association lifecycle. Apps no longer need a custom installation step to store the Commerce Base +URL. + +**Association and unassociation are handled automatically.** When `commerce-app-management` +associates an app with a Commerce instance, the SDK writes `AIO_COMMERCE_BASE_URL` and +`AIO_COMMERCE_ENV` to the OpenWhisk package. When the app is unassociated, those parameters are +removed. The runtime action always reflects the current association state. + +**If `getCommerceSystemConfig` returns `null`**, the app is either not currently associated with a +Commerce instance, or the association predates this feature. Apps must handle this case explicitly. + +**Available fields** returned by `getCommerceSystemConfig`: + +| Field | Type | Description | +| --------- | ------------------ | ---------------------------------------- | +| `baseUrl` | `string` | Commerce API base URL | +| `env` | `"saas" \| "paas"` | Deployment type of the Commerce instance | + +## Design + +### Storage + +The Commerce system configuration is stored as OpenWhisk package parameters on the package that +owns the installation action. Two reserved parameter names are used: + +- `AIO_COMMERCE_BASE_URL` — the Commerce API base URL +- `AIO_COMMERCE_ENV` — the deployment type (`"saas"` or `"paas"`) + +OpenWhisk injects all package parameters into every action's `params` object at invocation time, +so the values are available to runtime actions automatically — no additional read step is required. +The stored type is: + +```ts +type CommerceSystemConfig = { + baseUrl: string; + env: "saas" | "paas"; +}; +``` + +### New routes on the installation action + +Two new routes are added to the existing `installation` runtime action in `aio-commerce-lib-app`. + +**`POST /installation/association`** + +Writes `AIO_COMMERCE_BASE_URL` and `AIO_COMMERCE_ENV` to the OpenWhisk package. Called by +`commerce-app-management` immediately after the Extension Manager record for the association is +created successfully. + +The handler: + +1. Derives the package name from `process.env.__OW_ACTION_NAME` (set automatically by OpenWhisk). +2. Fetches the current package configuration via `ow.packages.get`. +3. Merges the two new parameters into the existing parameter list, preserving all others. +4. Writes the updated list back via `ow.packages.update`. + +Request body: + +```ts +{ + commerceBaseUrl: string; + commerceEnv: "saas" | "paas"; +} +``` + +Response: `200 OK` with the stored `CommerceSystemConfig`. The operation is idempotent — +re-associating with a different instance overwrites the previous values. + +**`DELETE /installation/association`** + +Removes `AIO_COMMERCE_BASE_URL` and `AIO_COMMERCE_ENV` from the OpenWhisk package parameters. +Called by `commerce-app-management` after unassociation completes. Other package parameters are +preserved. + +Response: `204 No Content`. + +These paths follow the same convention already established by `/installation/uninstallation`. + +### New `./runtime` export from `aio-commerce-lib-app` + +A new export entry `@adobe/aio-commerce-lib-app/runtime` exposes a typed helper for use inside +runtime actions: + +```ts +/** + * Returns the Commerce system configuration populated during association, + * or null if the app is not currently associated with a Commerce instance. + */ +export function getCommerceSystemConfig( + params: RuntimeActionParams, +): CommerceSystemConfig | null; +``` + +`params` is the standard `RuntimeActionParams` object every runtime action receives. The helper +reads `AIO_COMMERCE_BASE_URL` and `AIO_COMMERCE_ENV` from `params` and returns a typed +`CommerceSystemConfig`, or `null` if either value is absent. The function is synchronous — no +async call is needed because the values are already present in `params`. + +### Changes to `commerce-app-management` + +**`useAssociateApp`:** After the `POST /v2/extensions` call to the Extension Manager succeeds, +call `POST /installation/association` on the app's installation endpoint with `commerceBaseUrl` +and `commerceEnv`. Both values are already available in scope from `useCommerceInstance()`. This +step is best-effort: a failure is logged but the Extension Manager record is not rolled back. + +**`useUnassociateApp`:** After unassociation completes (whether via the new `POST /uninstallation` +path or the legacy `DELETE /installation` fallback), call `DELETE /installation/association` to +remove the stored parameters. This step is also best-effort. + +**`UnassociateAppDialog`:** The dialog previously took a shortcut for apps in +`APP_STATUS_ASSOCIATED` state — it called `handleUninstallSuccess()` directly without going +through `useUnassociateApp`, so `DELETE /installation/association` was never called for those +apps. The fix exposes `clearCommerceSystemConfig` from `useUnassociateApp` and calls it in the +shortcut branch before completing the unassociation. + +### Edge cases + +- **Association before this feature existed.** Apps associated before `POST /association` was + introduced have no stored parameters. `getCommerceSystemConfig` returns `null`. Apps must handle + this explicitly. +- **Association endpoint unavailable.** If the app's runtime is not deployed or the action + returns an unexpected error, `commerce-app-management` logs the failure and continues — the + Extension Manager record is still valid. The parameters will be absent until the app is + re-associated. +- **Concurrent associations.** The last write wins. No conflict detection is required. +- **Package name derivation.** The package name is derived from `process.env.__OW_ACTION_NAME`, + which OpenWhisk sets automatically on every action invocation. The format is + `/namespace/package-name/action-name`; the package name is the second-to-last segment. + +## Drawbacks + +- Introduces a new network call from `commerce-app-management` to the app's runtime action as + part of the association flow. This creates a new failure mode: the Extension Manager record can + succeed while the parameter update fails, leaving the app associated but without the stored + parameters. +- The `POST /association` handler performs a read-then-write on the OpenWhisk package to preserve + existing parameters. This is two API calls and is not atomic; a concurrent update between the + read and write could cause a parameter to be lost. + +## Rationale and alternatives + +**Why OpenWhisk package parameters?** Package parameters are the standard mechanism OpenWhisk uses +to deliver configuration to actions — they are injected automatically into every action's `params` +object at invocation time. Runtime actions receive the Commerce URL and environment the same way +they receive `LOG_LEVEL` or `OAUTH_CLIENT_ID`: no extra call, no extra dependency. + +**Why routes on the existing installation action rather than a new `association` action?** The +installation action is already the entry point `commerce-app-management` calls throughout the app +lifecycle, and its endpoint is already known from the app's extension points metadata. Reusing it +avoids introducing a new action name that must be deployed, registered, and separately discovered. +The `/association` sub-path is consistent with the existing `/uninstallation` sub-path pattern. + +**Why a `./runtime` export rather than reading params directly?** A dedicated helper provides a +typed return value and documents the reserved parameter names in one place. Apps that read +`params.AIO_COMMERCE_BASE_URL` directly are free to do so — the helper is a convenience, not a +requirement. + +**What is the impact of not doing this?** Every app that needs the Commerce Base URL in runtime +actions continues to implement its own storage logic, with no automatic cleanup on unassociation +and no standardized retrieval API. + +## Future possibilities + +- The stored config could be extended with additional parameters (e.g. store view code, API + version) without changing the public helper signature. +- `getCommerceSystemConfig` could become the foundation for a higher-level helper that initialises + a ready-to-use Commerce HTTP client, removing even more boilerplate from app developers. From 92644b923940e370431cde696e1355ce0abccb50 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Fri, 22 May 2026 07:25:05 -0500 Subject: [PATCH 02/46] CEXT-6160: add association-data spec --- .changeset/humble-walls-shout.md | 5 - packages/aio-commerce-lib-app/docs/usage.md | 41 --- packages/aio-commerce-lib-app/package.json | 11 - .../source/actions/installation.ts | 96 +------ .../aio-commerce-lib-app/source/runtime.ts | 50 ---- .../test/unit/actions/installation.test.ts | 161 +---------- .../test/unit/runtime/index.test.ts | 72 ----- .../aio-commerce-lib-app/tsdown.config.ts | 1 - specs/features/CEXT-6160-association-data.md | 247 +++++++++++++++++ ...0-commerce-system-config-on-association.md | 254 ------------------ 10 files changed, 249 insertions(+), 689 deletions(-) delete mode 100644 .changeset/humble-walls-shout.md delete mode 100644 packages/aio-commerce-lib-app/source/runtime.ts delete mode 100644 packages/aio-commerce-lib-app/test/unit/runtime/index.test.ts create mode 100644 specs/features/CEXT-6160-association-data.md delete mode 100644 specs/features/CEXT-6160-commerce-system-config-on-association.md diff --git a/.changeset/humble-walls-shout.md b/.changeset/humble-walls-shout.md deleted file mode 100644 index 98f064f59..000000000 --- a/.changeset/humble-walls-shout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@adobe/aio-commerce-lib-app": minor ---- - -Expose the Commerce system configuration (Base URL and deployment type) to runtime actions automatically when an app is associated with a Commerce instance, and clear it on unassociation. diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index 682a07d62..c58df3eac 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -705,47 +705,6 @@ try { } ``` -## Runtime Helpers - -The `@adobe/aio-commerce-lib-app/runtime` export provides helpers for use inside runtime actions. - -### Commerce System Configuration - -When `commerce-app-management` associates an app with a Commerce instance, the SDK automatically -stores the Commerce system configuration. Use `getCommerceSystemConfig` to retrieve it from any -runtime action — no custom storage setup is required. - -```ts -import { getCommerceSystemConfig } from "@adobe/aio-commerce-lib-app/runtime"; - -export async function main(params) { - const config = getCommerceSystemConfig(params); - if (!config) { - // App is not currently associated with a Commerce instance. - return { - statusCode: 400, - body: { error: "Not associated with a Commerce instance" }, - }; - } - - // config.baseUrl — Commerce API base URL, e.g. "https://my-store.example.com" - // config.env — "saas" | "paas" -} -``` - -When `getCommerceSystemConfig` returns `null`, the app is either not currently associated with a -Commerce instance, or the association predates this feature. Apps must handle this case explicitly. - -The configuration is cleared automatically when the app is unassociated, so runtime actions always -reflect the current association state. - -#### Available fields - -| Field | Type | Description | -| --------- | ------------------ | ---------------------------------------- | -| `baseUrl` | `string` | Commerce API base URL | -| `env` | `"saas" \| "paas"` | Deployment type of the Commerce instance | - ## 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 d3d9f1ab6..580fb99c5 100644 --- a/packages/aio-commerce-lib-app/package.json +++ b/packages/aio-commerce-lib-app/package.json @@ -63,16 +63,6 @@ "default": "./dist/cjs/management/index.cjs" } }, - "./runtime": { - "import": { - "types": "./dist/es/runtime.d.mts", - "default": "./dist/es/runtime.mjs" - }, - "require": { - "types": "./dist/cjs/runtime.d.cts", - "default": "./dist/cjs/runtime.cjs" - } - }, "./package.json": "./package.json" } }, @@ -80,7 +70,6 @@ "./actions": "./source/actions/index.ts", "./config": "./source/config/index.ts", "./management": "./source/management/index.ts", - "./runtime": "./source/runtime.ts", "./package.json": "./package.json" }, "imports": { diff --git a/packages/aio-commerce-lib-app/source/actions/installation.ts b/packages/aio-commerce-lib-app/source/actions/installation.ts index a922e283c..28dca54ec 100644 --- a/packages/aio-commerce-lib-app/source/actions/installation.ts +++ b/packages/aio-commerce-lib-app/source/actions/installation.ts @@ -24,7 +24,7 @@ import { } from "@aio-commerce-sdk/common-utils/actions"; import { createCombinedStore } from "@aio-commerce-sdk/common-utils/storage"; import openwhisk from "openwhisk"; -import { object, picklist, string } from "valibot"; +import { object, string } from "valibot"; import { createInitialInstallationState, @@ -49,7 +49,6 @@ import type { InProgressInstallationState, InstallationState, } from "#management/installation/workflow/types"; -import type { CommerceSystemConfig } from "#runtime"; // Action name for async invocation const DEFAULT_ACTION_NAME = "app-management/installation"; @@ -83,12 +82,6 @@ const InstallationRequestBodySchema = object({ ioEventsEnv: string(), }); -/** Request body schema for POST /association. */ -const AssociationRequestBodySchema = object({ - commerceBaseUrl: string(), - commerceEnv: picklist(["saas", "paas"]), -}); - type WorkflowRequestBody = { appData: InstallationContext["appData"]; commerceBaseUrl: string; @@ -118,43 +111,6 @@ function createUninstallationStore() { return createWorkflowStore("uninstallation"); } -const PARAM_COMMERCE_BASE_URL = "AIO_COMMERCE_BASE_URL"; -const PARAM_COMMERCE_ENV = "AIO_COMMERCE_ENV"; - -/** Extracts the OpenWhisk package name from an action name. */ -function getPackageName(actionName: string): string { - const parts = actionName.split("/").filter(Boolean); - if (parts.length < 2) { - throw new Error(`Cannot determine package from action name: ${actionName}`); - } - return parts.at(-2) as string; -} - -/** - * Merges the given key/value updates into the package's existing parameters, - * removing entries whose value is undefined. - */ -async function syncPackageParams( - packageName: string, - updates: Record, -): Promise { - const ow = openwhisk(); - const current = await ow.packages.get({ name: packageName }); - const existing = (current.parameters ?? []) as Array<{ - key: string; - value: string; - }>; - const updateKeys = Object.keys(updates); - const kept = existing.filter((p) => !updateKeys.includes(p.key)); - const added = updateKeys - .filter((key) => updates[key] !== undefined) - .map((key) => ({ key, value: updates[key] as string })); - await ow.packages.update({ - name: packageName, - package: { parameters: [...kept, ...added] }, - }); -} - /** Returns the storage key used to store the current installation ID. */ function getStorageKey() { // For simplicity, we use a single key to store the current installation state. @@ -644,56 +600,6 @@ router.delete("/uninstallation", { }, }); -/** - * POST /association - Store Commerce system config - * - * Called by commerce-app-management after associating the app with a Commerce instance. - * Stores the Base URL and env (PaaS/SaaS) for retrieval by runtime actions via - * getCommerceSystemConfig(). - */ -router.post("/association", { - body: AssociationRequestBodySchema, - - handler: async (req, { logger }) => { - const { commerceBaseUrl, commerceEnv } = req.body; - logger.debug( - `Storing Commerce system config: baseUrl=${commerceBaseUrl}, env=${commerceEnv}`, - ); - - const packageName = getPackageName(process.env.__OW_ACTION_NAME ?? ""); - await syncPackageParams(packageName, { - [PARAM_COMMERCE_BASE_URL]: commerceBaseUrl, - [PARAM_COMMERCE_ENV]: commerceEnv, - }); - - const config: CommerceSystemConfig = { - baseUrl: commerceBaseUrl, - env: commerceEnv, - }; - return ok({ body: config }); - }, -}); - -/** - * DELETE /association - Clear Commerce system config - * - * Called by commerce-app-management after unassociating the app from a Commerce instance. - * Removes the stored config so runtime actions no longer have access to stale data. - */ -router.delete("/association", { - handler: async (_req, { logger }) => { - logger.debug("Clearing Commerce system config"); - - const packageName = getPackageName(process.env.__OW_ACTION_NAME ?? ""); - await syncPackageParams(packageName, { - [PARAM_COMMERCE_BASE_URL]: undefined, - [PARAM_COMMERCE_ENV]: undefined, - }); - - return noContent(); - }, -}); - /** Factory to create the route handler for the `installation` action. */ export const installationRuntimeAction = ({ appConfig, customScriptsLoader }: RuntimeActionFactoryArgs) => diff --git a/packages/aio-commerce-lib-app/source/runtime.ts b/packages/aio-commerce-lib-app/source/runtime.ts deleted file mode 100644 index 2ba179517..000000000 --- a/packages/aio-commerce-lib-app/source/runtime.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; - -/** The Commerce system configuration populated during app association. */ -export type CommerceSystemConfig = { - /** The Commerce API base URL for the associated instance. */ - baseUrl: string; - /** The deployment type of the associated Commerce instance. */ - env: "saas" | "paas"; -}; - -/** - * Returns the Commerce system configuration populated when the app was associated - * with a Commerce instance, or `null` if the app is not currently associated. - * - * The values are available as the reserved package parameters `AIO_COMMERCE_BASE_URL` - * and `AIO_COMMERCE_ENV`, set automatically by the SDK during association. - * - * @example - * ```ts - * const config = getCommerceSystemConfig(params); - * if (!config) { - * return { statusCode: 400, body: { error: "Not associated with a Commerce instance" } }; - * } - * // config.baseUrl — Commerce API base URL - * // config.env — "saas" | "paas" - * ``` - */ -export function getCommerceSystemConfig( - params: RuntimeActionParams, -): CommerceSystemConfig | null { - const p = params as Record; - const baseUrl = p.AIO_COMMERCE_BASE_URL; - const env = p.AIO_COMMERCE_ENV; - if (typeof baseUrl !== "string" || typeof env !== "string") { - return null; - } - return { baseUrl, env: env as "saas" | "paas" }; -} diff --git a/packages/aio-commerce-lib-app/test/unit/actions/installation.test.ts b/packages/aio-commerce-lib-app/test/unit/actions/installation.test.ts index 151b5e03f..85d53fc25 100644 --- a/packages/aio-commerce-lib-app/test/unit/actions/installation.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/actions/installation.test.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; const { invokeMock, @@ -21,12 +21,8 @@ const { runInstallationMock, runUninstallationMock, runValidationMock, - mockPackagesGet, - mockPackagesUpdate, } = vi.hoisted(() => { const invokeMock = vi.fn(); - const mockPackagesGet = vi.fn(); - const mockPackagesUpdate = vi.fn(); return { invokeMock, @@ -34,10 +30,6 @@ const { actions: { invoke: invokeMock, }, - packages: { - get: mockPackagesGet, - update: mockPackagesUpdate, - }, })), createCombinedStoreMock: vi.fn(), createInitialInstallationStateMock: vi.fn(), @@ -45,8 +37,6 @@ const { runInstallationMock: vi.fn(), runUninstallationMock: vi.fn(), runValidationMock: vi.fn(), - mockPackagesGet, - mockPackagesUpdate, }; }); @@ -794,153 +784,4 @@ describe("installationRuntimeAction", () => { }); }); }); - - describe("POST /association", () => { - beforeEach(() => { - process.env.__OW_ACTION_NAME = "/namespace/test-package/installation"; - mockPackagesGet.mockResolvedValue({ parameters: [] }); - mockPackagesUpdate.mockResolvedValue({}); - }); - - afterEach(() => { - delete process.env.__OW_ACTION_NAME; - }); - - test("stores config as package params and returns 200 with the stored config", async () => { - const handler = installationRuntimeAction({ - appConfig: minimalValidConfig, - }); - - const result = await handler( - createRuntimeActionParams({ - method: "post", - path: "/association", - body: { - commerceBaseUrl: "https://commerce.example.com", - commerceEnv: "saas", - }, - }), - ); - - expect(result).toMatchObject({ - type: "success", - statusCode: 200, - body: { baseUrl: "https://commerce.example.com", env: "saas" }, - }); - expect(mockPackagesUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - name: "test-package", - package: expect.objectContaining({ - parameters: expect.arrayContaining([ - { - key: "AIO_COMMERCE_BASE_URL", - value: "https://commerce.example.com", - }, - { key: "AIO_COMMERCE_ENV", value: "saas" }, - ]), - }), - }), - ); - }); - - test("accepts paas env", async () => { - const handler = installationRuntimeAction({ - appConfig: minimalValidConfig, - }); - - const result = await handler( - createRuntimeActionParams({ - method: "post", - path: "/association", - body: { - commerceBaseUrl: "https://commerce.example.com", - commerceEnv: "paas", - }, - }), - ); - - expect(result).toMatchObject({ - type: "success", - statusCode: 200, - body: { env: "paas" }, - }); - }); - - test("returns 400 when commerceBaseUrl is missing", async () => { - const handler = installationRuntimeAction({ - appConfig: minimalValidConfig, - }); - - const result = await handler( - createRuntimeActionParams({ - method: "post", - path: "/association", - body: { commerceEnv: "saas" }, - }), - ); - - expect(result).toMatchObject({ error: { statusCode: 400 } }); - }); - - test("returns 400 when commerceEnv is invalid", async () => { - const handler = installationRuntimeAction({ - appConfig: minimalValidConfig, - }); - - const result = await handler( - createRuntimeActionParams({ - method: "post", - path: "/association", - body: { - commerceBaseUrl: "https://commerce.example.com", - commerceEnv: "invalid-env", - }, - }), - ); - - expect(result).toMatchObject({ error: { statusCode: 400 } }); - }); - }); - - describe("DELETE /association", () => { - beforeEach(() => { - process.env.__OW_ACTION_NAME = "/namespace/test-package/installation"; - mockPackagesUpdate.mockResolvedValue({}); - }); - - afterEach(() => { - delete process.env.__OW_ACTION_NAME; - }); - - test("removes package params and returns 204", async () => { - mockPackagesGet.mockResolvedValue({ - parameters: [ - { - key: "AIO_COMMERCE_BASE_URL", - value: "https://commerce.example.com", - }, - { key: "AIO_COMMERCE_ENV", value: "saas" }, - { key: "OTHER_PARAM", value: "keep-me" }, - ], - }); - - const handler = installationRuntimeAction({ - appConfig: minimalValidConfig, - }); - - const result = await handler( - createRuntimeActionParams({ method: "delete", path: "/association" }), - ); - - expect(result).toMatchObject({ type: "success", statusCode: 204 }); - expect(mockPackagesUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - name: "test-package", - package: expect.objectContaining({ - parameters: [{ key: "OTHER_PARAM", value: "keep-me" }], - }), - }), - ); - }); - }); }); diff --git a/packages/aio-commerce-lib-app/test/unit/runtime/index.test.ts b/packages/aio-commerce-lib-app/test/unit/runtime/index.test.ts deleted file mode 100644 index 7a672cdb0..000000000 --- a/packages/aio-commerce-lib-app/test/unit/runtime/index.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * 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 { describe, expect, test } from "vitest"; - -import { getCommerceSystemConfig } from "#runtime"; - -import type { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; - -function makeParams( - overrides: Record = {}, -): RuntimeActionParams { - return overrides as unknown as RuntimeActionParams; -} - -describe("getCommerceSystemConfig", () => { - test("returns the stored config when both params are present", () => { - const result = getCommerceSystemConfig( - makeParams({ - AIO_COMMERCE_BASE_URL: "https://commerce.example.com", - AIO_COMMERCE_ENV: "saas", - }), - ); - - expect(result).toEqual({ - baseUrl: "https://commerce.example.com", - env: "saas", - }); - }); - - test("returns config for paas env", () => { - const result = getCommerceSystemConfig( - makeParams({ - AIO_COMMERCE_BASE_URL: "https://commerce.example.com", - AIO_COMMERCE_ENV: "paas", - }), - ); - - expect(result).toEqual({ - baseUrl: "https://commerce.example.com", - env: "paas", - }); - }); - - test("returns null when AIO_COMMERCE_BASE_URL is absent", () => { - const result = getCommerceSystemConfig( - makeParams({ AIO_COMMERCE_ENV: "saas" }), - ); - expect(result).toBeNull(); - }); - - test("returns null when AIO_COMMERCE_ENV is absent", () => { - const result = getCommerceSystemConfig( - makeParams({ AIO_COMMERCE_BASE_URL: "https://commerce.example.com" }), - ); - expect(result).toBeNull(); - }); - - test("returns null when params are empty", () => { - const result = getCommerceSystemConfig(makeParams()); - expect(result).toBeNull(); - }); -}); diff --git a/packages/aio-commerce-lib-app/tsdown.config.ts b/packages/aio-commerce-lib-app/tsdown.config.ts index bfe3b5b29..6006fc4b6 100644 --- a/packages/aio-commerce-lib-app/tsdown.config.ts +++ b/packages/aio-commerce-lib-app/tsdown.config.ts @@ -24,7 +24,6 @@ export default mergeConfig(baseConfig, { "./source/config/index.ts", "./source/commands/index.ts", "./source/management/index.ts", - "./source/runtime.ts", ], define: { __PKG_VERSION__: JSON.stringify(pkg.version), diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md new file mode 100644 index 000000000..bb777b44d --- /dev/null +++ b/specs/features/CEXT-6160-association-data.md @@ -0,0 +1,247 @@ +# Association Data + +- **Ticket:** [CEXT-6160](https://jira.corp.adobe.com/browse/CEXT-6160) +- **Created:** 2026-05-21 +- [ ] **Implemented** + +## Summary + +Store the Commerce instance details, Base URL and deployment type, when an app is associated +with a Commerce instance, and expose a typed async helper so that any runtime action can retrieve them without custom storage setup. + +## Motivation + +Apps built on App Builder frequently need to call the Commerce API from their runtime actions. +To do so, they need the Base URL of the Commerce instance they are associated with and the +deployment type (PaaS or SaaS). Today each app reimplements this independently, typically by storing the Base URL during a custom installation step. + +This pattern has two problems: + +1. **Duplication.** Every app that needs to call the Commerce API writes the same storage and + retrieval logic. +2. **Wrong lifecycle.** Storing config during installation does not keep it in sync with the + association state. If the app is re-associated with a different instance, or unassociated, the stored config is not updated or cleared. + +The SDK already receives `commerceBaseUrl` and `commerceEnv` at every association and +unassociation event. The missing piece is a standard persistence mechanism tied to the association lifecycle and a typed helper for runtime actions to consume it. + +**Goals:** + +- Store the Commerce Base URL and deployment type when an app is associated with an instance. +- Clear the stored data when the app is unassociated. +- Expose a typed async helper that any runtime action can use to retrieve the data. +- Work for all app types, including those without a custom installation flow. + +**Non-goals:** + +- Storing any data beyond Base URL and deployment type in this iteration. +- Changing the existing `AIO_COMMERCE_API_BASE_URL` and `AIO_COMMERCE_API_FLAVOR` + deployment-time parameters set in `actions.config.yaml`. +- Providing any mechanism for apps to override or augment the stored data. + +## Developer experience + +After this feature ships, any runtime action can retrieve the associated Commerce instance +details with a single async call: + +```ts +import { getAssociatedCommerceInstance } from "@adobe/aio-commerce-lib-app"; + +export async function main(params) { + const instance = await getAssociatedCommerceInstance(params); + + if (!instance) { + return { + statusCode: 400, + body: { error: "App is not associated with a Commerce instance." }, + }; + } + + // instance.baseUrl — e.g. "https://my-store.example.com" + // instance.env — "saas" | "paas" + + const response = await fetch(`${instance.baseUrl}/rest/V1/products`, { + headers: { Authorization: `Bearer ${params.COMMERCE_API_TOKEN}` }, + }); +} +``` + +No custom storage setup is required. The SDK manages the data automatically during the +association lifecycle. The helper works from any runtime action regardless of which OpenWhisk +package the action belongs to. + +**If `getAssociatedCommerceInstance` returns `null`**, the app is either not currently associated or was associated before this feature was introduced. Apps must handle this case explicitly. + +**Available fields** on the returned object: + +| Field | Type | Description | +| --------- | ------------------ | ---------------------------------------- | +| `baseUrl` | `string` | Commerce API base URL | +| `env` | `"saas" \| "paas"` | Deployment type of the Commerce instance | + +## Design + +### Storage + +The association data is stored using the infrastructure already established in +**`aio-commerce-lib-config`** — specifically, the shared `getSharedState()` utility from +`aio-commerce-lib-config/source/utils/repository.ts`, which provides a lazy-initialized +Adobe I/O State client shared across the SDK. + +A new module is added to `aio-commerce-lib-config` specifically for association data, +separate from the existing Business Configuration module. It uses the same `getSharedState()` +client but stores under a dedicated reserved key (`association`) rather than the +`configuration.{scopeCode}` keys used by Business Configuration. + +This has two important properties for this use case: + +- **Package-agnostic.** Adobe I/O State is shared across all actions within the same App + Builder application, regardless of which OpenWhisk package they belong to. +- **No parameter drilling.** The helper reads directly from state. Callers do not need to + pass the data through every layer of the call stack. + +The new module exposes three internal functions used by `aio-commerce-lib-app`: + +```ts +// aio-commerce-lib-config (new internal module) +setAssociationData(data: AssociatedCommerceInstance): Promise +getAssociationData(): Promise +clearAssociationData(): Promise +``` + +The stored type is: + +```ts +type AssociatedCommerceInstance = { + baseUrl: string; + env: "saas" | "paas"; +}; +``` + +The shape is designed to extend future fields (e.g. `projectId`, `workspaceId`) can be +added without a breaking change. + +### New `association` runtime action + +A standalone `association` runtime action is added to `aio-commerce-lib-app`. It is always +deployed alongside `app-config` — not gated on any feature, so all app types have a +reachable endpoint regardless of which features they use. + +**`POST /`** — Store association data + +Request body: + +```ts +{ + commerceBaseUrl: string; + commerceEnv: "saas" | "paas"; +} +``` + +The handler validates the body and calls `setAssociationData` from the new +`aio-commerce-lib-config` association module. The operation is idempotent — re-associating +with a different instance overwrites the previous values. + +Response: `200 OK`. + +**`DELETE /`** — Clear association data + +Calls `clearAssociationData` from the `aio-commerce-lib-config` association module. + +Response: `204 No Content`. + +Both routes use the `HttpActionRouter` with the `logger` middleware, consistent with the +patterns used across other runtime actions in `aio-commerce-lib-app`. + +### New `getAssociatedCommerceInstance` helper + +A new export is added to the root entrypoint of `@adobe/aio-commerce-lib-app`: + +```ts +/** + * Returns the Commerce instance this app is currently associated with, + * or null if the app is not associated or was associated before this + * feature was introduced. + */ +export async function getAssociatedCommerceInstance( + params: RuntimeActionParams, +): Promise; +``` + +`params` is the standard params object every runtime action receives. Internally it calls +`getAssociationData` from the `aio-commerce-lib-config` association module. The function is +async because the underlying Adobe I/O State read is a network call. + +### Client integration + +Any client that manages the app association lifecycle is responsible for driving two calls +against the `association` endpoint. The endpoint URL is discoverable from the app's extension +points metadata, registered in `workerProcess` alongside the existing `app-config` and +`installation` hrefs. + +**On association** — after the app is successfully registered with the Extension Manager, +the client calls `POST /` with the Commerce instance details. This step is best effort a failure does not roll back the registration. + +**On unassociation** — after unassociation completes, the client calls `DELETE /` to remove +the stored data. This step is also best-effort. + +### Edge cases + +- **Apps associated before this feature.** No stored data exists; `getAssociatedCommerceInstance` + returns `null`. Apps must handle this explicitly. +- **Association endpoint unreachable.** If the runtime is not deployed or returns an unexpected + error, the calling client should log the failure and continue. The stored data will be absent + until the app is re-associated. +- **Concurrent associations.** The last write wins. No conflict detection is required. + +## Drawbacks + +- Adds a new runtime action to every app. +- `getAssociatedCommerceInstance` is async. Callers must await it, unlike a direct `params` read. +- Introduces a `aio-commerce-lib-config` network call on every action invocation that calls + the helper. + +## Rationale and alternatives + +**Why `aio-commerce-lib-config` infrastructure rather than OpenWhisk package parameters?** +OpenWhisk package parameters are scoped to a single package. Writing params to the +`app-management` package makes them available only to `app-management` actions. Developer +runtime actions in other packages never receive them, making the feature ineffective for +the primary use case. Adobe I/O State — accessed via the shared `getSharedState()` utility +already established in `aio-commerce-lib-config` — is accessible to all actions within the +same application regardless of package boundaries. + +**Why a new module in `aio-commerce-lib-config` 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. +Association data is app-level metadata, not scope-specific. A dedicated module with its own +reserved key (`association`) keeps the two concerns clearly separated while reusing the same +underlying storage infrastructure. + +**Why a standalone `association` action?** +The `installation` action is conditionally deployed, apps without custom install steps, +webhooks, events, or Admin UI SDK do not deploy it. Using it as the host would silently skip +the store call for those apps. A dedicated always-deployed action with a single responsibility +is the correct model and was agreed upon by the team. + +**Why export from the root entrypoint?** +A dedicated subpath adds indirection without benefit. Exporting from `@adobe/aio-commerce-lib-app` +directly keeps the import consistent with how the rest of the SDK is consumed. + +**What is the impact of not doing this?** +Every app that needs the Commerce Base URL in runtime actions continues to implement its own +storage and retrieval logic with no automatic cleanup on unassociation and no standardised API. + +## Unresolved questions + +- **Reserved config key naming.** The exact key used to store the data in `aio-commerce-lib-config` + needs to be defined as a reserved SDK concern so that apps do not accidentally collide with it. +- **Backfill for legacy apps.** A mechanism to backfill stored data for apps associated before + this feature was introduced is out of scope but should be tracked. + +## Future possibilities + +- The stored shape can be extended with `projectId` and `workspaceId` at association time, + providing data needed by other planned SDK features without requiring a new association step. +- `getAssociatedCommerceInstance` could become the foundation for a higher-level helper that + initialises a ready-to-use Commerce HTTP client. diff --git a/specs/features/CEXT-6160-commerce-system-config-on-association.md b/specs/features/CEXT-6160-commerce-system-config-on-association.md deleted file mode 100644 index 37f1ff52f..000000000 --- a/specs/features/CEXT-6160-commerce-system-config-on-association.md +++ /dev/null @@ -1,254 +0,0 @@ -# Commerce System Configuration on Association - -- **Ticket:** [CEXT-6160](https://jira.corp.adobe.com/browse/CEXT-6160) -- **Created:** 2026-05-14 -- [x] **Implemented** - -## Summary - -Store the Commerce system configuration — Base URL and deployment type (PaaS or SaaS) — as -reserved OpenWhisk package parameters when an app is associated with a Commerce instance, so that -runtime actions receive them automatically without each app reimplementing the same storage logic. -Clear the parameters when the app is unassociated. - -## Motivation - -Most Commerce apps need to call the Commerce API from their runtime actions. To do so, they need -the Base URL of the instance they are associated with and the deployment type (PaaS or SaaS). -Today, each app reimplements this logic independently: the B2B Approval Demo app, for example, -stores the Base URL in Business Configuration during a custom installation step. - -This pattern has two problems: - -1. **Duplication.** Every app that needs to call the Commerce API writes the same storage and - retrieval logic. -2. **Wrong lifecycle.** Storing the config during installation does not keep it in sync with the - association state. If the app is associated with a different instance, or unassociated, the - stored config is not updated or cleared. - -The SDK is already passed `commerceBaseUrl` and `commerceEnv` at every association and -unassociation event — the data apps need is already flowing through the system. The missing piece -is persistence tied to the association lifecycle and a standard way for runtime actions to consume -it. - -**Goals:** - -- Store the Commerce Base URL and deployment type (PaaS/SaaS) as OpenWhisk package parameters - when an app is associated with a Commerce instance. -- Clear the stored parameters when the app is unassociated. -- Expose a typed helper that runtime actions can use to read the values from their params. - -**Non-goals:** - -- Storing any Commerce system configuration beyond Base URL and deployment type. -- Replacing the `AIO_COMMERCE_API_BASE_URL` and `AIO_COMMERCE_API_FLAVOR` package params set - during installation. Those remain unchanged. -- Providing a mechanism for apps to override or augment the stored config. - -## Developer experience - -After this feature ships, the Commerce system configuration is available directly in the action's -`params` object under two reserved names: - -| Reserved parameter | Description | -| ----------------------- | ------------------------------------------------ | -| `AIO_COMMERCE_BASE_URL` | Commerce API base URL of the associated instance | -| `AIO_COMMERCE_ENV` | Deployment type: `"saas"` or `"paas"` | - -A runtime action can read these directly: - -```js -export async function main(params) { - const baseUrl = params.AIO_COMMERCE_BASE_URL; - const env = params.AIO_COMMERCE_ENV; -} -``` - -Or use the typed helper from the `./runtime` export: - -```ts -import { getCommerceSystemConfig } from "@adobe/aio-commerce-lib-app/runtime"; - -export async function main(params) { - const config = getCommerceSystemConfig(params); - if (!config) { - // App is not associated with a Commerce instance - return { - statusCode: 400, - body: { error: "Not associated with a Commerce instance" }, - }; - } - - // config.baseUrl — the Commerce Base URL, e.g. "https://my-store.example.com" - // config.env — "saas" | "paas" -} -``` - -No custom storage setup is required. The SDK manages the parameters transparently during the -association lifecycle. Apps no longer need a custom installation step to store the Commerce Base -URL. - -**Association and unassociation are handled automatically.** When `commerce-app-management` -associates an app with a Commerce instance, the SDK writes `AIO_COMMERCE_BASE_URL` and -`AIO_COMMERCE_ENV` to the OpenWhisk package. When the app is unassociated, those parameters are -removed. The runtime action always reflects the current association state. - -**If `getCommerceSystemConfig` returns `null`**, the app is either not currently associated with a -Commerce instance, or the association predates this feature. Apps must handle this case explicitly. - -**Available fields** returned by `getCommerceSystemConfig`: - -| Field | Type | Description | -| --------- | ------------------ | ---------------------------------------- | -| `baseUrl` | `string` | Commerce API base URL | -| `env` | `"saas" \| "paas"` | Deployment type of the Commerce instance | - -## Design - -### Storage - -The Commerce system configuration is stored as OpenWhisk package parameters on the package that -owns the installation action. Two reserved parameter names are used: - -- `AIO_COMMERCE_BASE_URL` — the Commerce API base URL -- `AIO_COMMERCE_ENV` — the deployment type (`"saas"` or `"paas"`) - -OpenWhisk injects all package parameters into every action's `params` object at invocation time, -so the values are available to runtime actions automatically — no additional read step is required. -The stored type is: - -```ts -type CommerceSystemConfig = { - baseUrl: string; - env: "saas" | "paas"; -}; -``` - -### New routes on the installation action - -Two new routes are added to the existing `installation` runtime action in `aio-commerce-lib-app`. - -**`POST /installation/association`** - -Writes `AIO_COMMERCE_BASE_URL` and `AIO_COMMERCE_ENV` to the OpenWhisk package. Called by -`commerce-app-management` immediately after the Extension Manager record for the association is -created successfully. - -The handler: - -1. Derives the package name from `process.env.__OW_ACTION_NAME` (set automatically by OpenWhisk). -2. Fetches the current package configuration via `ow.packages.get`. -3. Merges the two new parameters into the existing parameter list, preserving all others. -4. Writes the updated list back via `ow.packages.update`. - -Request body: - -```ts -{ - commerceBaseUrl: string; - commerceEnv: "saas" | "paas"; -} -``` - -Response: `200 OK` with the stored `CommerceSystemConfig`. The operation is idempotent — -re-associating with a different instance overwrites the previous values. - -**`DELETE /installation/association`** - -Removes `AIO_COMMERCE_BASE_URL` and `AIO_COMMERCE_ENV` from the OpenWhisk package parameters. -Called by `commerce-app-management` after unassociation completes. Other package parameters are -preserved. - -Response: `204 No Content`. - -These paths follow the same convention already established by `/installation/uninstallation`. - -### New `./runtime` export from `aio-commerce-lib-app` - -A new export entry `@adobe/aio-commerce-lib-app/runtime` exposes a typed helper for use inside -runtime actions: - -```ts -/** - * Returns the Commerce system configuration populated during association, - * or null if the app is not currently associated with a Commerce instance. - */ -export function getCommerceSystemConfig( - params: RuntimeActionParams, -): CommerceSystemConfig | null; -``` - -`params` is the standard `RuntimeActionParams` object every runtime action receives. The helper -reads `AIO_COMMERCE_BASE_URL` and `AIO_COMMERCE_ENV` from `params` and returns a typed -`CommerceSystemConfig`, or `null` if either value is absent. The function is synchronous — no -async call is needed because the values are already present in `params`. - -### Changes to `commerce-app-management` - -**`useAssociateApp`:** After the `POST /v2/extensions` call to the Extension Manager succeeds, -call `POST /installation/association` on the app's installation endpoint with `commerceBaseUrl` -and `commerceEnv`. Both values are already available in scope from `useCommerceInstance()`. This -step is best-effort: a failure is logged but the Extension Manager record is not rolled back. - -**`useUnassociateApp`:** After unassociation completes (whether via the new `POST /uninstallation` -path or the legacy `DELETE /installation` fallback), call `DELETE /installation/association` to -remove the stored parameters. This step is also best-effort. - -**`UnassociateAppDialog`:** The dialog previously took a shortcut for apps in -`APP_STATUS_ASSOCIATED` state — it called `handleUninstallSuccess()` directly without going -through `useUnassociateApp`, so `DELETE /installation/association` was never called for those -apps. The fix exposes `clearCommerceSystemConfig` from `useUnassociateApp` and calls it in the -shortcut branch before completing the unassociation. - -### Edge cases - -- **Association before this feature existed.** Apps associated before `POST /association` was - introduced have no stored parameters. `getCommerceSystemConfig` returns `null`. Apps must handle - this explicitly. -- **Association endpoint unavailable.** If the app's runtime is not deployed or the action - returns an unexpected error, `commerce-app-management` logs the failure and continues — the - Extension Manager record is still valid. The parameters will be absent until the app is - re-associated. -- **Concurrent associations.** The last write wins. No conflict detection is required. -- **Package name derivation.** The package name is derived from `process.env.__OW_ACTION_NAME`, - which OpenWhisk sets automatically on every action invocation. The format is - `/namespace/package-name/action-name`; the package name is the second-to-last segment. - -## Drawbacks - -- Introduces a new network call from `commerce-app-management` to the app's runtime action as - part of the association flow. This creates a new failure mode: the Extension Manager record can - succeed while the parameter update fails, leaving the app associated but without the stored - parameters. -- The `POST /association` handler performs a read-then-write on the OpenWhisk package to preserve - existing parameters. This is two API calls and is not atomic; a concurrent update between the - read and write could cause a parameter to be lost. - -## Rationale and alternatives - -**Why OpenWhisk package parameters?** Package parameters are the standard mechanism OpenWhisk uses -to deliver configuration to actions — they are injected automatically into every action's `params` -object at invocation time. Runtime actions receive the Commerce URL and environment the same way -they receive `LOG_LEVEL` or `OAUTH_CLIENT_ID`: no extra call, no extra dependency. - -**Why routes on the existing installation action rather than a new `association` action?** The -installation action is already the entry point `commerce-app-management` calls throughout the app -lifecycle, and its endpoint is already known from the app's extension points metadata. Reusing it -avoids introducing a new action name that must be deployed, registered, and separately discovered. -The `/association` sub-path is consistent with the existing `/uninstallation` sub-path pattern. - -**Why a `./runtime` export rather than reading params directly?** A dedicated helper provides a -typed return value and documents the reserved parameter names in one place. Apps that read -`params.AIO_COMMERCE_BASE_URL` directly are free to do so — the helper is a convenience, not a -requirement. - -**What is the impact of not doing this?** Every app that needs the Commerce Base URL in runtime -actions continues to implement its own storage logic, with no automatic cleanup on unassociation -and no standardized retrieval API. - -## Future possibilities - -- The stored config could be extended with additional parameters (e.g. store view code, API - version) without changing the public helper signature. -- `getCommerceSystemConfig` could become the foundation for a higher-level helper that initialises - a ready-to-use Commerce HTTP client, removing even more boilerplate from app developers. From 9f2d51e5721a6a2cbb9dfaaca3228a05a97b5bae Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 27 May 2026 15:42:47 -0500 Subject: [PATCH 03/46] address review feedback --- specs/features/CEXT-6160-association-data.md | 92 +++++++++++++++----- 1 file changed, 71 insertions(+), 21 deletions(-) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index bb777b44d..47a0aacfe 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -7,7 +7,7 @@ ## Summary Store the Commerce instance details, Base URL and deployment type, when an app is associated -with a Commerce instance, and expose a typed async helper so that any runtime action can retrieve them without custom storage setup. +with a Commerce instance, and expose two typed async helpers: a low-level one that returns the raw instance data, and a higher-level one that returns a ready-to-use `AdobeCommerceHttpClient` — so that any runtime action can call the Commerce API without custom storage setup or client construction boilerplate. ## Motivation @@ -23,13 +23,13 @@ This pattern has two problems: association state. If the app is re-associated with a different instance, or unassociated, the stored config is not updated or cleared. The SDK already receives `commerceBaseUrl` and `commerceEnv` at every association and -unassociation event. The missing piece is a standard persistence mechanism tied to the association lifecycle and a typed helper for runtime actions to consume it. +unassociation event. The missing piece is a standard persistence mechanism tied to the association lifecycle and typed helpers for runtime actions to consume it. **Goals:** - Store the Commerce Base URL and deployment type when an app is associated with an instance. - Clear the stored data when the app is unassociated. -- Expose a typed async helper that any runtime action can use to retrieve the data. +- Expose typed async helpers that any runtime action can use to retrieve the data and construct a ready-to-use Commerce HTTP client. - Work for all app types, including those without a custom installation flow. **Non-goals:** @@ -41,8 +41,29 @@ unassociation event. The missing piece is a standard persistence mechanism tied ## Developer experience -After this feature ships, any runtime action can retrieve the associated Commerce instance -details with a single async call: +After this feature ships, any runtime action can call the Commerce API without custom storage +setup or client construction boilerplate. + +**Primary pattern — get a ready-to-use client:** + +```ts +import { getAssociatedCommerceClient } from "@adobe/aio-commerce-lib-app"; + +export async function main(params) { + const client = await getAssociatedCommerceClient(params); + + if (!client) { + return { + statusCode: 400, + body: { error: "App is not associated with a Commerce instance." }, + }; + } + + const products = await client.get("rest/V1/products").json(); +} +``` + +**Low-level pattern — get the raw instance data:** ```ts import { getAssociatedCommerceInstance } from "@adobe/aio-commerce-lib-app"; @@ -59,20 +80,16 @@ export async function main(params) { // instance.baseUrl — e.g. "https://my-store.example.com" // instance.env — "saas" | "paas" - - const response = await fetch(`${instance.baseUrl}/rest/V1/products`, { - headers: { Authorization: `Bearer ${params.COMMERCE_API_TOKEN}` }, - }); } ``` No custom storage setup is required. The SDK manages the data automatically during the -association lifecycle. The helper works from any runtime action regardless of which OpenWhisk +association lifecycle. Both helpers work from any runtime action regardless of which OpenWhisk package the action belongs to. -**If `getAssociatedCommerceInstance` returns `null`**, the app is either not currently associated or was associated before this feature was introduced. Apps must handle this case explicitly. +**If either helper returns `null`**, the app is either not currently associated or was associated before this feature was introduced. Apps must handle this case explicitly. -**Available fields** on the returned object: +**Available fields** on the `AssociatedCommerceInstance` object: | Field | Type | Description | | --------- | ------------------ | ---------------------------------------- | @@ -93,6 +110,8 @@ separate from the existing Business Configuration module. It uses the same `getS client but stores under a dedicated reserved key (`association`) rather than the `configuration.{scopeCode}` keys used by Business Configuration. +The data is stored with the maximum TTL of 1 year (31536000 seconds). The default TTL of 24 hours is not appropriate here — association data is long-lived and must not expire on its own. It should only be cleared explicitly on unassociation. Re-associating resets the TTL. + This has two important properties for this use case: - **Package-agnostic.** Adobe I/O State is shared across all actions within the same App @@ -127,6 +146,10 @@ A standalone `association` runtime action is added to `aio-commerce-lib-app`. It deployed alongside `app-config` — not gated on any feature, so all app types have a reachable endpoint regardless of which features they use. +The action is protected with `require-adobe-auth: true`, the same as all other actions in +`aio-commerce-lib-app`. Only requests carrying a valid Adobe IMS token can call it, preventing +unauthorised writes or deletions of the stored association data. + **`POST /`** — Store association data Request body: @@ -172,6 +195,32 @@ export async function getAssociatedCommerceInstance( `getAssociationData` from the `aio-commerce-lib-config` association module. The function is async because the underlying Adobe I/O State read is a network call. +### New `getAssociatedCommerceClient` helper + +A higher-level export from `@adobe/aio-commerce-lib-app` that builds on +`getAssociatedCommerceInstance` and returns a ready-to-use `AdobeCommerceHttpClient` from +`@adobe/aio-commerce-lib-api`: + +```ts +/** + * Returns an initialised AdobeCommerceHttpClient for the Commerce instance this app is + * currently associated with, or null if the app is not associated or was associated before + * this feature was introduced. + */ +export async function getAssociatedCommerceClient( + params: RuntimeActionParams, +): Promise; +``` + +Internally it calls `getAssociatedCommerceInstance` and, if data is available, constructs an +`AdobeCommerceHttpClient` using `baseUrl` and `env` from the stored association data combined +with the auth credentials present in `params`. Returns `null` when no association data is +found. + +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. + ### Client integration Any client that manages the app association lifecycle is responsible for driving two calls @@ -179,19 +228,22 @@ against the `association` endpoint. The endpoint URL is discoverable from the ap points metadata, registered in `workerProcess` alongside the existing `app-config` and `installation` hrefs. -**On association** — after the app is successfully registered with the Extension Manager, -the client calls `POST /` with the Commerce instance details. This step is best effort a failure does not roll back the registration. +**On association** — after the app is registered with the Extension Manager, the client +calls `POST /` with the Commerce instance details. If this call fails, the association +fails entirely — the app cannot be marked as successfully associated if the Commerce +configuration was not saved. The client is responsible for surfacing the failure to the +developer (e.g. rolling back the Extension Manager registration or otherwise preventing +the app from being shown as successfully associated). **On unassociation** — after unassociation completes, the client calls `DELETE /` to remove -the stored data. This step is also best-effort. +the stored data. This step is best-effort — a failure does not block the unassociation. ### Edge cases - **Apps associated before this feature.** No stored data exists; `getAssociatedCommerceInstance` returns `null`. Apps must handle this explicitly. - **Association endpoint unreachable.** If the runtime is not deployed or returns an unexpected - error, the calling client should log the failure and continue. The stored data will be absent - until the app is re-associated. + error, the association fails. The developer can retry once the endpoint is reachable. - **Concurrent associations.** The last write wins. No conflict detection is required. ## Drawbacks @@ -200,6 +252,8 @@ the stored data. This step is also best-effort. - `getAssociatedCommerceInstance` is async. Callers must await it, unlike a direct `params` read. - Introduces a `aio-commerce-lib-config` network call on every action invocation that calls the helper. +- Association fails entirely if the config storage endpoint is unreachable. A transient + failure blocks the whole association flow, requiring the developer to retry. ## Rationale and alternatives @@ -234,8 +288,6 @@ storage and retrieval logic with no automatic cleanup on unassociation and no st ## Unresolved questions -- **Reserved config key naming.** The exact key used to store the data in `aio-commerce-lib-config` - needs to be defined as a reserved SDK concern so that apps do not accidentally collide with it. - **Backfill for legacy apps.** A mechanism to backfill stored data for apps associated before this feature was introduced is out of scope but should be tracked. @@ -243,5 +295,3 @@ storage and retrieval logic with no automatic cleanup on unassociation and no st - The stored shape can be extended with `projectId` and `workspaceId` at association time, providing data needed by other planned SDK features without requiring a new association step. -- `getAssociatedCommerceInstance` could become the foundation for a higher-level helper that - initialises a ready-to-use Commerce HTTP client. From 204f842fc1e0671c8ac6f5c301cada0f91e9548a Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Fri, 29 May 2026 13:03:15 -0500 Subject: [PATCH 04/46] address review feedback --- specs/features/CEXT-6160-association-data.md | 28 +++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index 47a0aacfe..75b44bbd8 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -7,7 +7,7 @@ ## Summary Store the Commerce instance details, Base URL and deployment type, when an app is associated -with a Commerce instance, and expose two typed async helpers: a low-level one that returns the raw instance data, and a higher-level one that returns a ready-to-use `AdobeCommerceHttpClient` — so that any runtime action can call the Commerce API without custom storage setup or client construction boilerplate. +with a Commerce instance, and expose two typed async helpers: a low-level one that returns the raw instance data, and a higher-level one that returns a ready-to-use `AdobeCommerceHttpClient`, so that any runtime action can call the Commerce API without custom storage setup or client construction boilerplate. ## Motivation @@ -110,7 +110,7 @@ separate from the existing Business Configuration module. It uses the same `getS client but stores under a dedicated reserved key (`association`) rather than the `configuration.{scopeCode}` keys used by Business Configuration. -The data is stored with the maximum TTL of 1 year (31536000 seconds). The default TTL of 24 hours is not appropriate here — association data is long-lived and must not expire on its own. It should only be cleared explicitly on unassociation. Re-associating resets the TTL. +The data is stored with the maximum TTL of 1 year (31536000 seconds). The default TTL of 24 hours is not appropriate here — association data is long-lived and must not expire on its own. It should only be cleared explicitly on unassociation. To keep the data alive, the TTL is refreshed on every read inside `getAssociationData`. So as long as any action reads the configuration at least once a year, the data won't expire. This has two important properties for this use case: @@ -223,17 +223,19 @@ duplicate. ### Client integration -Any client that manages the app association lifecycle is responsible for driving two calls -against the `association` endpoint. The endpoint URL is discoverable from the app's extension -points metadata, registered in `workerProcess` alongside the existing `app-config` and -`installation` hrefs. - -**On association** — after the app is registered with the Extension Manager, the client -calls `POST /` with the Commerce instance details. If this call fails, the association -fails entirely — the app cannot be marked as successfully associated if the Commerce -configuration was not saved. The client is responsible for surfacing the failure to the -developer (e.g. rolling back the Extension Manager registration or otherwise preventing -the app from being shown as successfully associated). +Any client that manages the app association lifecycle is responsible for calling `POST /` +when the app is associated and `DELETE /` when the app is unassociated. The endpoint URL is +discoverable from the app's extension points metadata, registered in `workerProcess` +alongside the existing `app-config` and `installation` hrefs. + +**On association** — the client calls `POST /` with the Commerce instance details first, +and only registers the app with the Extension Manager if that call succeeds. This way, the +app is never marked as associated unless the Commerce configuration was saved, and no +rollback is needed. + +For older SDK versions where the `/association` endpoint isn't registered in the app's +extension points metadata, the client should skip the `POST /` call and proceed with just +the Extension Manager registration. **On unassociation** — after unassociation completes, the client calls `DELETE /` to remove the stored data. This step is best-effort — a failure does not block the unassociation. From 07a1aae0239d751834d7ce69904b47ad0e510713 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 2 Jun 2026 23:41:10 -0500 Subject: [PATCH 05/46] CEXT-6160: rename to getCommerceInstance/getCommerceClient Drop the Associated prefix from the public helpers per Daniel's review feedback - cleaner, more discoverable API. --- specs/features/CEXT-6160-association-data.md | 24 ++++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index 75b44bbd8..6734422aa 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -47,10 +47,10 @@ setup or client construction boilerplate. **Primary pattern — get a ready-to-use client:** ```ts -import { getAssociatedCommerceClient } from "@adobe/aio-commerce-lib-app"; +import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; export async function main(params) { - const client = await getAssociatedCommerceClient(params); + const client = await getCommerceClient(params); if (!client) { return { @@ -66,10 +66,10 @@ export async function main(params) { **Low-level pattern — get the raw instance data:** ```ts -import { getAssociatedCommerceInstance } from "@adobe/aio-commerce-lib-app"; +import { getCommerceInstance } from "@adobe/aio-commerce-lib-app"; export async function main(params) { - const instance = await getAssociatedCommerceInstance(params); + const instance = await getCommerceInstance(params); if (!instance) { return { @@ -176,7 +176,7 @@ Response: `204 No Content`. Both routes use the `HttpActionRouter` with the `logger` middleware, consistent with the patterns used across other runtime actions in `aio-commerce-lib-app`. -### New `getAssociatedCommerceInstance` helper +### New `getCommerceInstance` helper A new export is added to the root entrypoint of `@adobe/aio-commerce-lib-app`: @@ -186,7 +186,7 @@ A new export is added to the root entrypoint of `@adobe/aio-commerce-lib-app`: * or null if the app is not associated or was associated before this * feature was introduced. */ -export async function getAssociatedCommerceInstance( +export async function getCommerceInstance( params: RuntimeActionParams, ): Promise; ``` @@ -195,10 +195,10 @@ export async function getAssociatedCommerceInstance( `getAssociationData` from the `aio-commerce-lib-config` association module. The function is async because the underlying Adobe I/O State read is a network call. -### New `getAssociatedCommerceClient` helper +### New `getCommerceClient` helper A higher-level export from `@adobe/aio-commerce-lib-app` that builds on -`getAssociatedCommerceInstance` and returns a ready-to-use `AdobeCommerceHttpClient` from +`getCommerceInstance` and returns a ready-to-use `AdobeCommerceHttpClient` from `@adobe/aio-commerce-lib-api`: ```ts @@ -207,12 +207,12 @@ A higher-level export from `@adobe/aio-commerce-lib-app` that builds on * currently associated with, or null if the app is not associated or was associated before * this feature was introduced. */ -export async function getAssociatedCommerceClient( +export async function getCommerceClient( params: RuntimeActionParams, ): Promise; ``` -Internally it calls `getAssociatedCommerceInstance` and, if data is available, constructs an +Internally it calls `getCommerceInstance` and, if data is available, constructs an `AdobeCommerceHttpClient` using `baseUrl` and `env` from the stored association data combined with the auth credentials present in `params`. Returns `null` when no association data is found. @@ -242,7 +242,7 @@ the stored data. This step is best-effort — a failure does not block the unass ### Edge cases -- **Apps associated before this feature.** No stored data exists; `getAssociatedCommerceInstance` +- **Apps associated before this feature.** No stored data exists; `getCommerceInstance` returns `null`. Apps must handle this explicitly. - **Association endpoint unreachable.** If the runtime is not deployed or returns an unexpected error, the association fails. The developer can retry once the endpoint is reachable. @@ -251,7 +251,7 @@ the stored data. This step is best-effort — a failure does not block the unass ## Drawbacks - Adds a new runtime action to every app. -- `getAssociatedCommerceInstance` is async. Callers must await it, unlike a direct `params` read. +- `getCommerceInstance` is async. Callers must await it, unlike a direct `params` read. - Introduces a `aio-commerce-lib-config` network call on every action invocation that calls the helper. - Association fails entirely if the config storage endpoint is unreachable. A transient From bd84bf8750eaa4d30e450c97f36d1a8739bce0c0 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 3 Jun 2026 00:04:50 -0500 Subject: [PATCH 06/46] CEXT-6160: throw AppNotAssociatedError instead of returning null Per Ivan's review feedback - 'not associated' is exceptional state, throwing keeps happy-path code clean without forcing null checks at every call site. --- specs/features/CEXT-6160-association-data.md | 45 +++++++------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index 6734422aa..a861568aa 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -51,14 +51,6 @@ import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; export async function main(params) { const client = await getCommerceClient(params); - - if (!client) { - return { - statusCode: 400, - body: { error: "App is not associated with a Commerce instance." }, - }; - } - const products = await client.get("rest/V1/products").json(); } ``` @@ -71,13 +63,6 @@ import { getCommerceInstance } from "@adobe/aio-commerce-lib-app"; export async function main(params) { const instance = await getCommerceInstance(params); - if (!instance) { - return { - statusCode: 400, - body: { error: "App is not associated with a Commerce instance." }, - }; - } - // instance.baseUrl — e.g. "https://my-store.example.com" // instance.env — "saas" | "paas" } @@ -87,7 +72,7 @@ No custom storage setup is required. The SDK manages the data automatically duri association lifecycle. Both helpers work from any runtime action regardless of which OpenWhisk package the action belongs to. -**If either helper returns `null`**, the app is either not currently associated or was associated before this feature was introduced. Apps must handle this case explicitly. +**If the app is not associated**, both helpers throw an `AppNotAssociatedError`. This applies to apps that have never been associated, were unassociated, or were associated by an older SDK that didn't store this data. Re-associating the app resolves the error. Apps that need to handle this case gracefully should wrap the call in a `try/catch`. **Available fields** on the `AssociatedCommerceInstance` object: @@ -182,13 +167,14 @@ A new export is added to the root entrypoint of `@adobe/aio-commerce-lib-app`: ```ts /** - * Returns the Commerce instance this app is currently associated with, - * or null if the app is not associated or was associated before this - * feature was introduced. + * 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 didn't store this data. */ export async function getCommerceInstance( params: RuntimeActionParams, -): Promise; +): Promise; ``` `params` is the standard params object every runtime action receives. Internally it calls @@ -203,19 +189,20 @@ A higher-level export from `@adobe/aio-commerce-lib-app` that builds on ```ts /** - * Returns an initialised AdobeCommerceHttpClient for the Commerce instance this app is - * currently associated with, or null if the app is not associated or was associated before - * this feature was introduced. + * Returns an initialised AdobeCommerceHttpClient for 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 didn't store this data. */ export async function getCommerceClient( params: RuntimeActionParams, -): Promise; +): Promise; ``` -Internally it calls `getCommerceInstance` and, if data is available, constructs an -`AdobeCommerceHttpClient` using `baseUrl` and `env` from the stored association data combined -with the auth credentials present in `params`. Returns `null` when no association data is -found. +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. 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 @@ -243,7 +230,7 @@ the stored data. This step is best-effort — a failure does not block the unass ### Edge cases - **Apps associated before this feature.** No stored data exists; `getCommerceInstance` - returns `null`. Apps must handle this explicitly. + throws `AppNotAssociatedError`. Re-associating the app resolves it. - **Association endpoint unreachable.** If the runtime is not deployed or returns an unexpected error, the association fails. The developer can retry once the endpoint is reachable. - **Concurrent associations.** The last write wins. No conflict detection is required. From ccd61dea3075faaa0f44116e6cbbbd464874080c Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 3 Jun 2026 00:09:04 -0500 Subject: [PATCH 07/46] CEXT-6160: use system.association as storage key Per Daniel's review feedback - puts SDK-managed config under a system.* namespace, cleanly separated from user-defined configuration.* keys. --- specs/features/CEXT-6160-association-data.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index a861568aa..8b14ff86a 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -92,8 +92,10 @@ Adobe I/O State client shared across the SDK. A new module is added to `aio-commerce-lib-config` specifically for association data, separate from the existing Business Configuration module. It uses the same `getSharedState()` -client but stores under a dedicated reserved key (`association`) rather than the -`configuration.{scopeCode}` keys used by Business Configuration. +client but stores under a dedicated reserved key (`system.association`) rather than the +`configuration.{scopeCode}` keys used by Business Configuration. The `system.*` namespace +keeps SDK-managed config (e.g. `system.association`, future `system.events`) cleanly +separated from app-defined `configuration.*` keys. The data is stored with the maximum TTL of 1 year (31536000 seconds). The default TTL of 24 hours is not appropriate here — association data is long-lived and must not expire on its own. It should only be cleared explicitly on unassociation. To keep the data alive, the TTL is refreshed on every read inside `getAssociationData`. So as long as any action reads the configuration at least once a year, the data won't expire. @@ -258,8 +260,8 @@ same application regardless of package boundaries. The existing `setConfiguration`/`getConfiguration` API in `aio-commerce-lib-config` is designed for Business Configuration — scope-tree based values keyed by Commerce scope codes. Association data is app-level metadata, not scope-specific. A dedicated module with its own -reserved key (`association`) keeps the two concerns clearly separated while reusing the same -underlying storage infrastructure. +reserved key (`system.association`) keeps the two concerns clearly separated while reusing the +same underlying storage infrastructure. **Why a standalone `association` action?** The `installation` action is conditionally deployed, apps without custom install steps, From 0457e1ede662c48a11eefd8dae030f529c643932 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 3 Jun 2026 00:25:51 -0500 Subject: [PATCH 08/46] CEXT-6160: split storage into generic + typed layers lib-config now exposes generic system config primitives (setSystemConfigByKey/getSystemConfigByKey), and lib-app has the typed association wrappers on top. Drops the delete operation - clearing is done by setting null. --- specs/features/CEXT-6160-association-data.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index 8b14ff86a..e0981b7ac 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -106,13 +106,27 @@ This has two important properties for this use case: - **No parameter drilling.** The helper reads directly from state. Callers do not need to pass the data through every layer of the call stack. -The new module exposes three internal functions used by `aio-commerce-lib-app`: +The storage uses a two-layered design: + +**`aio-commerce-lib-config`** exposes generic, domain-agnostic primitives. No knowledge of +App Management or "association" — just key-value access for any SDK-managed system config. +Setting the value to `null` clears the entry, so there is no separate delete operation: + +```ts +// aio-commerce-lib-config (generic system config module) +setSystemConfigByKey(key: string, value: unknown | null): Promise +getSystemConfigByKey(key: string): Promise +``` + +**`aio-commerce-lib-app`** exposes typed, domain-aware wrappers on top of those primitives — +they encode the `system.association` key and the `AssociatedCommerceInstance` type so +runtime actions get a strongly-typed API: ```ts -// aio-commerce-lib-config (new internal module) +// aio-commerce-lib-app (association module) setAssociationData(data: AssociatedCommerceInstance): Promise getAssociationData(): Promise -clearAssociationData(): Promise +clearAssociationData(): Promise // calls setSystemConfigByKey("system.association", null) ``` The stored type is: From 42dfcccc231e6759d748770c0bfc55ae1fc0f2d3 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 3 Jun 2026 00:56:39 -0500 Subject: [PATCH 09/46] CEXT-6160: update spec to reference correct modules Typed association helpers (setAssociationData, getAssociationData, clearAssociationData) live in lib-app per the layered design, not lib-config. lib-config only exposes the generic system config primitives. --- specs/features/CEXT-6160-association-data.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index e0981b7ac..f556a367f 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -163,14 +163,14 @@ Request body: ``` The handler validates the body and calls `setAssociationData` from the new -`aio-commerce-lib-config` association module. The operation is idempotent — re-associating +`aio-commerce-lib-app` association module. The operation is idempotent — re-associating with a different instance overwrites the previous values. Response: `200 OK`. **`DELETE /`** — Clear association data -Calls `clearAssociationData` from the `aio-commerce-lib-config` association module. +Calls `clearAssociationData` from the `aio-commerce-lib-app` association module. Response: `204 No Content`. @@ -194,8 +194,9 @@ export async function getCommerceInstance( ``` `params` is the standard params object every runtime action receives. Internally it calls -`getAssociationData` from the `aio-commerce-lib-config` association module. The function is -async because the underlying Adobe I/O State read is a network call. +`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 Adobe I/O State read is a network call. ### New `getCommerceClient` helper From 4eed58200477af76e5a1d3142e334db560e362ef Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 3 Jun 2026 01:29:44 -0500 Subject: [PATCH 10/46] CEXT-6160: clarify generic lib-config module and helper layering in spec --- specs/features/CEXT-6160-association-data.md | 32 +++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index f556a367f..e2472bd25 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -90,12 +90,15 @@ The association data is stored using the infrastructure already established in `aio-commerce-lib-config/source/utils/repository.ts`, which provides a lazy-initialized Adobe I/O State client shared across the SDK. -A new module is added to `aio-commerce-lib-config` specifically for association data, -separate from the existing Business Configuration module. It uses the same `getSharedState()` -client but stores under a dedicated reserved key (`system.association`) rather than the -`configuration.{scopeCode}` keys used by Business Configuration. The `system.*` namespace -keeps SDK-managed config (e.g. `system.association`, future `system.events`) cleanly -separated from app-defined `configuration.*` keys. +A new generic `system-config` module is added to `aio-commerce-lib-config` for any +SDK-managed system data, separate from the existing Business Configuration module. It uses +the same `getSharedState()` client 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 module is fully +domain-agnostic — it has no knowledge of "association" or any App Management concept; the +association-aware logic lives in `aio-commerce-lib-app` and uses this generic module +underneath. The data is stored with the maximum TTL of 1 year (31536000 seconds). The default TTL of 24 hours is not appropriate here — association data is long-lived and must not expire on its own. It should only be cleared explicitly on unassociation. To keep the data alive, the TTL is refreshed on every read inside `getAssociationData`. So as long as any action reads the configuration at least once a year, the data won't expire. @@ -129,6 +132,10 @@ getAssociationData(): Promise clearAssociationData(): Promise // calls setSystemConfigByKey("system.association", null) ``` +These typed helpers are internal — used by the `association` runtime action handlers and by +the public-facing helpers. The public exports developers use are `getCommerceInstance` and +`getCommerceClient`, which throw `AppNotAssociatedError` instead of returning null. + The stored type is: ```ts @@ -271,12 +278,15 @@ the primary use case. Adobe I/O State — accessed via the shared `getSharedStat already established in `aio-commerce-lib-config` — is accessible to all actions within the same application regardless of package boundaries. -**Why a new module in `aio-commerce-lib-config` rather than using `setConfiguration` directly?** +**Why a new generic `system-config` module in `aio-commerce-lib-config` 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. -Association data is app-level metadata, not scope-specific. A dedicated module with its own -reserved key (`system.association`) keeps the two concerns clearly separated while reusing the -same underlying storage infrastructure. +designed for Business Configuration — scope-tree based values keyed by Commerce scope codes +with inheritance. Association data is app-level metadata, not scope-specific, and doesn't +need inheritance. A generic `system-config` module with `setSystemConfigByKey` / +`getSystemConfigByKey` primitives keeps the two concerns clearly separated while reusing the +same underlying storage infrastructure. The module is domain-agnostic — no knowledge of +"association" or any App Management concept — so it can be reused for 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, From 434ebe20e8b8572ca8d9dc05dd91ff58eb2079d1 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 3 Jun 2026 01:40:30 -0500 Subject: [PATCH 11/46] CEXT-6160: document that system config bypasses scope tree --- specs/features/CEXT-6160-association-data.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index e2472bd25..393eb9326 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -100,6 +100,10 @@ domain-agnostic — it has no knowledge of "association" or any App Management c association-aware logic lives in `aio-commerce-lib-app` and uses this generic module underneath. +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 +`configuration.*` namespaces are parallel, not nested. + The data is stored with the maximum TTL of 1 year (31536000 seconds). The default TTL of 24 hours is not appropriate here — association data is long-lived and must not expire on its own. It should only be cleared explicitly on unassociation. To keep the data alive, the TTL is refreshed on every read inside `getAssociationData`. So as long as any action reads the configuration at least once a year, the data won't expire. This has two important properties for this use case: From 2ffbd155a61cdaab0b5e5094479dce80b2a70688 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 3 Jun 2026 10:27:09 -0500 Subject: [PATCH 12/46] CEXT-6160: drop TTL refresh, document two-layer storage lib-config provides persistence via lib-files with lib-state as a performance cache. Cache expiry triggers automatic re-fetch from files, so no TTL refresh logic is needed in our code. --- specs/features/CEXT-6160-association-data.md | 33 +++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index 393eb9326..3ca775963 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -86,15 +86,18 @@ package the action belongs to. ### Storage The association data is stored using the infrastructure already established in -**`aio-commerce-lib-config`** — specifically, the shared `getSharedState()` utility from -`aio-commerce-lib-config/source/utils/repository.ts`, which provides a lazy-initialized -Adobe I/O State client shared across the SDK. +**`aio-commerce-lib-config`** — specifically, the shared `getSharedState()` and +`getSharedFiles()` utilities from `aio-commerce-lib-config/source/utils/repository.ts`. +`lib-config` uses a two-layer storage pattern: `lib-files` for persistent storage (source of +truth, no TTL) and `lib-state` as a performance cache (with TTL). When a cached value +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-config` module is added to `aio-commerce-lib-config` for any SDK-managed system data, separate from the existing Business Configuration module. It uses -the same `getSharedState()` client 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 +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 module is fully domain-agnostic — it has no knowledge of "association" or any App Management concept; the association-aware logic lives in `aio-commerce-lib-app` and uses this generic module @@ -104,13 +107,11 @@ System config does not participate in the Commerce scope tree — `getSystemConf performs a direct key lookup with no inheritance or fallback chain. The `system.*` and `configuration.*` namespaces are parallel, not nested. -The data is stored with the maximum TTL of 1 year (31536000 seconds). The default TTL of 24 hours is not appropriate here — association data is long-lived and must not expire on its own. It should only be cleared explicitly on unassociation. To keep the data alive, the TTL is refreshed on every read inside `getAssociationData`. So as long as any action reads the configuration at least once a year, the data won't expire. - This has two important properties for this use case: -- **Package-agnostic.** Adobe I/O State is shared across all actions within the same App - Builder application, regardless of which OpenWhisk package they belong to. -- **No parameter drilling.** The helper reads directly from state. Callers do not need to +- **Package-agnostic.** The shared storage is accessible from all actions within the same + App Builder application, regardless of which OpenWhisk package they belong to. +- **No parameter drilling.** The helper reads directly from storage. Callers do not need to pass the data through every layer of the call stack. The storage uses a two-layered design: @@ -207,7 +208,8 @@ export async function getCommerceInstance( `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 Adobe I/O State read is a network call. +function is async because the underlying storage read (lib-state cache with lib-files +fallback) is a network call. ### New `getCommerceClient` helper @@ -278,9 +280,10 @@ the stored data. This step is best-effort — a failure does not block the unass OpenWhisk package parameters are scoped to a single package. Writing params to the `app-management` package makes them available only to `app-management` actions. Developer runtime actions in other packages never receive them, making the feature ineffective for -the primary use case. Adobe I/O State — accessed via the shared `getSharedState()` utility -already established in `aio-commerce-lib-config` — is accessible to all actions within the -same application regardless of package boundaries. +the primary use case. `aio-commerce-lib-config`'s shared storage (lib-files for persistence + +- 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-config` module in `aio-commerce-lib-config` rather than using `setConfiguration` directly?** The existing `setConfiguration`/`getConfiguration` API in `aio-commerce-lib-config` is From 2b1c005ede773dc543fc4da5bf7816c03703ab75 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 3 Jun 2026 10:48:08 -0500 Subject: [PATCH 13/46] CEXT-6160: nest system module under configuration/ --- specs/features/CEXT-6160-association-data.md | 46 +++++++++----------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index 3ca775963..1c75867a5 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -93,15 +93,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-config` module is added to `aio-commerce-lib-config` for any -SDK-managed system data, separate from the existing Business Configuration module. 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 module is fully -domain-agnostic — it has no knowledge of "association" or any App Management concept; the -association-aware logic lives in `aio-commerce-lib-app` and uses this generic module -underneath. +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`. 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 @@ -116,12 +117,12 @@ This has two important properties for this use case: The storage uses a two-layered design: -**`aio-commerce-lib-config`** exposes generic, domain-agnostic primitives. No knowledge of -App Management or "association" — just key-value access for any SDK-managed system config. -Setting the value to `null` clears the entry, so there is no separate delete operation: +**`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: ```ts -// aio-commerce-lib-config (generic system config module) +// aio-commerce-lib-config (generic system submodule under configuration/) setSystemConfigByKey(key: string, value: unknown | null): Promise getSystemConfigByKey(key: string): Promise ``` @@ -285,15 +286,15 @@ 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-config` module in `aio-commerce-lib-config` rather than using `setConfiguration` directly?** +**Why a new generic `system` submodule inside `configuration/` 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. Association data is app-level metadata, not scope-specific, and doesn't -need inheritance. A generic `system-config` module with `setSystemConfigByKey` / -`getSystemConfigByKey` primitives keeps the two concerns clearly separated while reusing the -same underlying storage infrastructure. The module is domain-agnostic — no knowledge of -"association" or any App Management concept — so it can be reused for other SDK-managed -data in the future (`system.events`, etc.). +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.). **Why a standalone `association` action?** The `installation` action is conditionally deployed, apps without custom install steps, @@ -309,11 +310,6 @@ directly keeps the import consistent with how the rest of the SDK is consumed. Every app that needs the Commerce Base URL in runtime actions continues to implement its own storage and retrieval logic with no automatic cleanup on unassociation and no standardised API. -## Unresolved questions - -- **Backfill for legacy apps.** A mechanism to backfill stored data for apps associated before - this feature was introduced is out of scope but should be tracked. - ## Future possibilities - The stored shape can be extended with `projectId` and `workspaceId` at association time, From bb79d735ece3d47e1b2e5ef62ddbdf2237a86c1d Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Thu, 4 Jun 2026 09:52:09 -0500 Subject: [PATCH 14/46] CEXT-6160: add generic system config submodule to lib-config Adds setSystemConfigByKey / getSystemConfigByKey primitives under modules/configuration/system/. Uses lib-files for persistent storage with lib-state as a performance cache. Passing null to set clears the entry. Exposed via the new @adobe/aio-commerce-lib-config/system subpath export. Domain-agnostic - operates purely on opaque keys and values. Will be used by lib-app's association module via the public subpath export. --- packages/aio-commerce-lib-config/package.json | 11 ++ .../modules/configuration/system/index.ts | 17 ++ .../configuration/system/system-repository.ts | 147 +++++++++++++++ .../system/system-repository.test.ts | 167 ++++++++++++++++++ .../aio-commerce-lib-config/tsdown.config.ts | 6 +- 5 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 packages/aio-commerce-lib-config/source/modules/configuration/system/index.ts create mode 100644 packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts create mode 100644 packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts diff --git a/packages/aio-commerce-lib-config/package.json b/packages/aio-commerce-lib-config/package.json index 01ffa9da1..5a2de26fd 100644 --- a/packages/aio-commerce-lib-config/package.json +++ b/packages/aio-commerce-lib-config/package.json @@ -48,11 +48,22 @@ "types": "./dist/cjs/index.d.cts", "default": "./dist/cjs/index.cjs" } + }, + "./system": { + "import": { + "types": "./dist/es/modules/configuration/system/index.d.mts", + "default": "./dist/es/modules/configuration/system/index.mjs" + }, + "require": { + "types": "./dist/cjs/modules/configuration/system/index.d.cts", + "default": "./dist/cjs/modules/configuration/system/index.cjs" + } } } }, "exports": { ".": "./source/index.ts", + "./system": "./source/modules/configuration/system/index.ts", "./package.json": "./package.json" }, "imports": { diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/system/index.ts b/packages/aio-commerce-lib-config/source/modules/configuration/system/index.ts new file mode 100644 index 000000000..9ce595500 --- /dev/null +++ b/packages/aio-commerce-lib-config/source/modules/configuration/system/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { + getSystemConfigByKey, + setSystemConfigByKey, +} from "./system-repository"; diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts b/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts new file mode 100644 index 000000000..5a8d23e11 --- /dev/null +++ b/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts @@ -0,0 +1,147 @@ +/* + * 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 stringify from "safe-stable-stringify"; + +import { getLogger } from "#utils/logger"; +import { getSharedFiles, getSharedState } from "#utils/repository"; + +const SYSTEM_FILES_PREFIX = "system/"; + +function getSystemFilePath(key: string): string { + return `${SYSTEM_FILES_PREFIX}${key}.json`; +} + +async function readFromCache(key: string): Promise { + try { + const state = await getSharedState(); + const result = await state.get(key); + return result?.value ?? null; + } catch { + return null; + } +} + +async function writeToCache(key: string, payload: string): Promise { + const logger = getLogger("@adobe/aio-commerce-lib-config:system-repository"); + try { + const state = await getSharedState(); + await state.put(key, payload); + } catch (error) { + logger.debug( + "Failed to cache system config:", + error instanceof Error ? error.message : String(error), + ); + } +} + +async function deleteFromCache(key: string): Promise { + const logger = getLogger("@adobe/aio-commerce-lib-config:system-repository"); + try { + const state = await getSharedState(); + await state.delete(key); + } catch (error) { + logger.debug( + "Failed to clear system config cache:", + error instanceof Error ? error.message : String(error), + ); + } +} + +async function readFromFiles(key: string): Promise { + try { + const files = await getSharedFiles(); + const filePath = getSystemFilePath(key); + const filesList = await files.list(SYSTEM_FILES_PREFIX); + const fileObject = filesList.find((file) => file.name === filePath); + + if (!fileObject) { + return null; + } + + const content = await files.read(filePath); + return content ? content.toString("utf8") : null; + } catch { + return null; + } +} + +async function writeToFiles(key: string, payload: string): Promise { + const files = await getSharedFiles(); + const filePath = getSystemFilePath(key); + await files.write(filePath, payload); +} + +async function deleteFromFiles(key: string): Promise { + const logger = getLogger("@adobe/aio-commerce-lib-config:system-repository"); + try { + const files = await getSharedFiles(); + const filePath = getSystemFilePath(key); + await files.delete(filePath); + } catch (error) { + logger.debug( + "Failed to delete system config file:", + error instanceof Error ? error.message : String(error), + ); + } +} + +/** + * Stores or clears a system configuration value by key. + * + * Persists the value to `aio-lib-files` (source of truth) and writes it to + * `aio-lib-state` as a performance cache. Passing `null` clears the entry from + * both storage layers. + * + * @param key - The system configuration key (e.g. `"system.association"`). + * @param value - The value to store, or `null` to clear the entry. + */ +export async function setSystemConfigByKey( + key: string, + value: unknown | null, +): Promise { + if (value === null) { + await deleteFromFiles(key); + await deleteFromCache(key); + return; + } + + const payload = stringify(value) as string; + await writeToFiles(key, payload); + await writeToCache(key, payload); +} + +/** + * Retrieves a system configuration value by key. + * + * Reads from the `aio-lib-state` cache first; on cache miss, falls back to + * `aio-lib-files` (the persistent source of truth) and re-caches the value for + * subsequent reads. 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 { + const cached = await readFromCache(key); + if (cached !== null) { + return JSON.parse(cached) as T; + } + + const persisted = await readFromFiles(key); + if (persisted === null) { + return null; + } + + // Re-cache for subsequent reads + await writeToCache(key, persisted); + return JSON.parse(persisted) as T; +} diff --git a/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts b/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts new file mode 100644 index 000000000..db9cd5edc --- /dev/null +++ b/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts @@ -0,0 +1,167 @@ +/* + * 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 { mockState, mockFiles } = vi.hoisted(() => ({ + mockState: { + get: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, + mockFiles: { + list: vi.fn(), + read: vi.fn(), + write: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("#utils/repository", () => ({ + getSharedState: vi.fn().mockResolvedValue(mockState), + getSharedFiles: vi.fn().mockResolvedValue(mockFiles), +})); + +describe("system-repository", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("setSystemConfigByKey", () => { + test("writes the serialized value to both files and state", async () => { + const { setSystemConfigByKey } = await import( + "#modules/configuration/system/system-repository" + ); + + const data = { baseUrl: "https://example.com", env: "paas" }; + await setSystemConfigByKey("system.association", data); + + expect(mockFiles.write).toHaveBeenCalledWith( + "system/system.association.json", + JSON.stringify(data), + ); + expect(mockState.put).toHaveBeenCalledWith( + "system.association", + JSON.stringify(data), + ); + }); + + test("clears both files and state when value is null", async () => { + const { setSystemConfigByKey } = await import( + "#modules/configuration/system/system-repository" + ); + + await setSystemConfigByKey("system.association", null); + + expect(mockFiles.delete).toHaveBeenCalledWith( + "system/system.association.json", + ); + expect(mockState.delete).toHaveBeenCalledWith("system.association"); + expect(mockFiles.write).not.toHaveBeenCalled(); + expect(mockState.put).not.toHaveBeenCalled(); + }); + + test("does not throw if cache write fails", async () => { + mockState.put.mockRejectedValueOnce(new Error("cache write failed")); + const { setSystemConfigByKey } = await import( + "#modules/configuration/system/system-repository" + ); + + await expect( + setSystemConfigByKey("system.association", { foo: "bar" }), + ).resolves.toBeUndefined(); + + expect(mockFiles.write).toHaveBeenCalled(); + }); + }); + + describe("getSystemConfigByKey", () => { + test("returns the cached value when present in state", async () => { + const data = { baseUrl: "https://example.com", env: "saas" }; + mockState.get.mockResolvedValue({ value: JSON.stringify(data) }); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system/system-repository" + ); + + const result = await getSystemConfigByKey("system.association"); + + expect(result).toEqual(data); + expect(mockFiles.list).not.toHaveBeenCalled(); + expect(mockFiles.read).not.toHaveBeenCalled(); + }); + + test("falls back to files when cache miss, then re-caches", async () => { + const data = { baseUrl: "https://example.com", env: "paas" }; + mockState.get.mockResolvedValue({ value: null }); + mockFiles.list.mockResolvedValue([ + { name: "system/system.association.json" }, + ]); + mockFiles.read.mockResolvedValue(Buffer.from(JSON.stringify(data))); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system/system-repository" + ); + + const result = await getSystemConfigByKey("system.association"); + + expect(result).toEqual(data); + expect(mockFiles.read).toHaveBeenCalledWith( + "system/system.association.json", + ); + expect(mockState.put).toHaveBeenCalledWith( + "system.association", + JSON.stringify(data), + ); + }); + + test("returns null when not found in cache or files", async () => { + mockState.get.mockResolvedValue({ value: null }); + mockFiles.list.mockResolvedValue([]); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system/system-repository" + ); + + const result = await getSystemConfigByKey("system.association"); + + expect(result).toBeNull(); + expect(mockState.put).not.toHaveBeenCalled(); + }); + + test("returns null when state read throws", async () => { + mockState.get.mockRejectedValue(new Error("state error")); + mockFiles.list.mockResolvedValue([]); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system/system-repository" + ); + + const result = await getSystemConfigByKey("system.association"); + + expect(result).toBeNull(); + }); + + test("returns null when files read throws", async () => { + mockState.get.mockResolvedValue({ value: null }); + mockFiles.list.mockRejectedValue(new Error("files error")); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system/system-repository" + ); + + const result = await getSystemConfigByKey("system.association"); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/aio-commerce-lib-config/tsdown.config.ts b/packages/aio-commerce-lib-config/tsdown.config.ts index 80c3b9887..6324c46ff 100644 --- a/packages/aio-commerce-lib-config/tsdown.config.ts +++ b/packages/aio-commerce-lib-config/tsdown.config.ts @@ -14,5 +14,9 @@ import { baseConfig } from "@aio-commerce-sdk/config-tsdown/tsdown.config.base"; import { mergeConfig } from "tsdown"; export default mergeConfig(baseConfig, { - entry: ["./source/index.ts", "./source/commands/index.ts"], + entry: [ + "./source/index.ts", + "./source/commands/index.ts", + "./source/modules/configuration/system/index.ts", + ], }); From dbbb3951fcb75e058e965ab928974eec708e4379 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Thu, 4 Jun 2026 10:51:45 -0500 Subject: [PATCH 15/46] CEXT-6160: add association module and public helpers to lib-app - AppNotAssociatedError class (extends CommerceSdkErrorBase) - Internal association module: setAssociationData / getAssociationData / clearAssociationData (calls into lib-config system submodule) - Public root entrypoint: getCommerceInstance and getCommerceClient helpers, both throw AppNotAssociatedError when no data is stored - Add root export to package.json and tsdown.config.ts --- packages/aio-commerce-lib-app/package.json | 11 ++ .../source/errors/app-not-associated-error.ts | 52 +++++++++ .../source/errors/index.ts | 14 +++ packages/aio-commerce-lib-app/source/index.ts | 102 ++++++++++++++++++ .../association/association-repository.ts | 54 ++++++++++ .../source/modules/association/index.ts | 20 ++++ .../source/modules/association/types.ts | 21 ++++ .../aio-commerce-lib-app/tsdown.config.ts | 1 + 8 files changed, 275 insertions(+) create mode 100644 packages/aio-commerce-lib-app/source/errors/app-not-associated-error.ts create mode 100644 packages/aio-commerce-lib-app/source/errors/index.ts create mode 100644 packages/aio-commerce-lib-app/source/index.ts create mode 100644 packages/aio-commerce-lib-app/source/modules/association/association-repository.ts create mode 100644 packages/aio-commerce-lib-app/source/modules/association/index.ts create mode 100644 packages/aio-commerce-lib-app/source/modules/association/types.ts diff --git a/packages/aio-commerce-lib-app/package.json b/packages/aio-commerce-lib-app/package.json index faee24e2d..e9dbcdac4 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/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..dbef639c5 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/errors/app-not-associated-error.ts @@ -0,0 +1,52 @@ +/* + * 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"; + +const DEFAULT_MESSAGE = + "App is not associated with a Commerce instance. Re-associate the app to resolve this error."; + +type AppNotAssociatedErrorOptions = ErrorOptions & { + traceId?: string; +}; + +/** + * 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"; + * + * try { + * const client = await getCommerceClient(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..d5cacd9c3 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/index.ts @@ -0,0 +1,102 @@ +/* + * 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 + */ + +import { + AdobeCommerceHttpClient, + resolveCommerceHttpClientParams, +} from "@adobe/aio-commerce-lib-api"; + +import { AppNotAssociatedError } from "./errors/app-not-associated-error"; +import { getAssociationData } from "./modules/association/association-repository"; + +import type { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; +import type { AssociatedCommerceInstance } from "./modules/association/types"; + +// biome-ignore lint/performance/noBarrelFile: export as part of the Public API +export { AppNotAssociatedError } from "./errors/app-not-associated-error"; + +export type { AssociatedCommerceInstance } from "./modules/association/types"; + +/** + * Returns the Commerce instance this app is currently associated with. + * + * @param _params - The standard params object every runtime action receives. + * @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(params) { + * const instance = await getCommerceInstance(params); + * + * // instance.baseUrl — e.g. "https://my-store.example.com" + * // instance.env — "saas" | "paas" + * } + * ``` + */ +export async function getCommerceInstance( + _params: RuntimeActionParams, +): 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. + * + * Internally calls {@link getCommerceInstance} and combines the stored + * `baseUrl` and `env` with the auth credentials present in `params` to build + * the client. + * + * @param params - The standard params object every runtime action receives. + * @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"; + * + * export async function main(params) { + * const client = await getCommerceClient(params); + * const products = await client.get("rest/V1/products").json(); + * } + * ``` + */ +export async function getCommerceClient( + params: RuntimeActionParams, +): Promise { + const instance = await getCommerceInstance(params); + + const httpClientParams = resolveCommerceHttpClientParams({ + ...params, + AIO_COMMERCE_API_BASE_URL: instance.baseUrl, + AIO_COMMERCE_API_FLAVOR: instance.env, + }); + + return new AdobeCommerceHttpClient(httpClientParams); +} diff --git a/packages/aio-commerce-lib-app/source/modules/association/association-repository.ts b/packages/aio-commerce-lib-app/source/modules/association/association-repository.ts new file mode 100644 index 000000000..71ed175c1 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/modules/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/system"; + +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/modules/association/index.ts b/packages/aio-commerce-lib-app/source/modules/association/index.ts new file mode 100644 index 000000000..fb59544be --- /dev/null +++ b/packages/aio-commerce-lib-app/source/modules/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/modules/association/types.ts b/packages/aio-commerce-lib-app/source/modules/association/types.ts new file mode 100644 index 000000000..1b47f7dee --- /dev/null +++ b/packages/aio-commerce-lib-app/source/modules/association/types.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +/** + * The Commerce instance an app is associated with. + */ +export type AssociatedCommerceInstance = { + /** Commerce API base URL. */ + baseUrl: string; + /** Deployment type of the Commerce instance. */ + env: "saas" | "paas"; +}; 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", From 1b1a82e5eaeba61b9151588c31d9e1ff89da4b21 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Thu, 4 Jun 2026 15:56:08 -0500 Subject: [PATCH 16/46] CEXT-6160: add association runtime action and scaffolding - New association runtime action with POST / and DELETE / handlers, protected with require-adobe-auth: true - POST / accepts { commerceBaseUrl, commerceEnv } and stores it via the internal association module - DELETE / clears the stored data - Add association.js.template for app scaffolding - Update buildAppManagementExtConfig to deploy the association action alongside app-config (always deployed, no feature gating) - Update OpenAPI spec with the new POST /association and DELETE /association routes, bump info.version to 1.1.0 - Update test fixtures and config test expectations --- .../aio-commerce-lib-app/docs/openapi.json | 117 +++++++++++++++++- .../source/actions/association/index.ts | 29 +++++ .../source/actions/association/router.ts | 76 ++++++++++++ .../source/actions/association/schema.ts | 19 +++ .../commands/generate/actions/config.ts | 5 + .../app-management/association.js.template | 18 +++ .../test/fixtures/commands.ts | 4 + .../commands/generate/actions/config.test.ts | 1 + 8 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 packages/aio-commerce-lib-app/source/actions/association/index.ts create mode 100644 packages/aio-commerce-lib-app/source/actions/association/router.ts create mode 100644 packages/aio-commerce-lib-app/source/actions/association/schema.ts create mode 100644 packages/aio-commerce-lib-app/source/commands/generate/actions/templates/app-management/association.js.template diff --git a/packages/aio-commerce-lib-app/docs/openapi.json b/packages/aio-commerce-lib-app/docs/openapi.json index 2574be573..7160a7beb 100644 --- a/packages/aio-commerce-lib-app/docs/openapi.json +++ b/packages/aio-commerce-lib-app/docs/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "App Management API", "description": "REST API for managing Adobe Commerce App Builder apps: resolving app configuration, managing configuration values, running the installation and uninstallation lifecycle, exposing the Admin UI SDK registration, and managing the scope tree.", - "version": "1.0.0", + "version": "1.1.0", "contact": {}, "license": { "identifier": "Apache-2.0", @@ -2635,6 +2635,121 @@ }, "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" + }, + "commerceEnv": { + "description": "Deployment type of the associated Commerce instance.", + "type": "string", + "enum": ["saas", "paas"] + } + }, + "required": ["commerceBaseUrl", "commerceEnv"] + } + } + } + }, + "responses": { + "200": { + "description": "Association data stored successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + }, + "env": { + "type": "string", + "enum": ["saas", "paas"] + } + }, + "required": ["baseUrl", "env"] + } + } + } + }, + "400": { + "description": "Bad request, the request body is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "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." + }, + "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/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..96dd88bd6 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/actions/association/router.ts @@ -0,0 +1,76 @@ +/* + * 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, ok } from "@adobe/aio-commerce-lib-core/responses"; +import { + HttpActionRouter, + logger, +} from "@aio-commerce-sdk/common-utils/actions"; + +import { + clearAssociationData, + setAssociationData, +} from "#modules/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 ok({ + body: { baseUrl: commerceBaseUrl, env: commerceEnv }, + }); + }, +}); + +/** + * 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..8a160ac86 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/actions/association/schema.ts @@ -0,0 +1,19 @@ +/* + * 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 * as v from "valibot"; + +/** Request body for POST / — store association data. */ +export const AssociationRequestBodySchema = v.object({ + commerceBaseUrl: v.string(), + commerceEnv: v.picklist(["saas", "paas"]), +}); 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 fb71c6d68..44bd18b17 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 @@ -124,6 +124,10 @@ export function buildAppManagementExtConfig( type: "action", impl: `${PACKAGE_NAME}/app-config`, }, + { + type: "action", + impl: `${PACKAGE_NAME}/association`, + }, ], }, @@ -133,6 +137,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/test/fixtures/commands.ts b/packages/aio-commerce-lib-app/test/fixtures/commands.ts index 467fc5e04..67f5a2ebf 100644 --- a/packages/aio-commerce-lib-app/test/fixtures/commands.ts +++ b/packages/aio-commerce-lib-app/test/fixtures/commands.ts @@ -3,6 +3,8 @@ import registrationTemplate from "#templates/admin-ui-sdk/registration.js?raw"; // @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"; @@ -13,6 +15,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, @@ -27,6 +30,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, "admin-ui-sdk/registration.js.template": templates.registration, 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 321bce8ce..65445f77d 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 @@ -139,6 +139,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" }, ]); }); From 3a1f2b48b68dbbb60e8a66cee799dc88608580b8 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Fri, 5 Jun 2026 10:43:07 -0500 Subject: [PATCH 17/46] CEXT-6160: add tests, changeset, and docs for association helpers - Unit tests for AppNotAssociatedError, association repository, public helpers (getCommerceInstance / getCommerceClient), and the association runtime action handlers (POST / and DELETE /) - changesets for minor bumps in lib-app and lib-config - Document the new helpers in lib-app usage.md --- .changeset/commerce-instance-helpers.md | 5 + .changeset/system-config-storage.md | 5 + packages/aio-commerce-lib-app/docs/usage.md | 79 ++++++++++ .../test/unit/actions/association.test.ts | 141 ++++++++++++++++++ .../errors/app-not-associated-error.test.ts | 59 ++++++++ .../test/unit/index.test.ts | 136 +++++++++++++++++ .../association-repository.test.ts | 88 +++++++++++ 7 files changed, 513 insertions(+) create mode 100644 .changeset/commerce-instance-helpers.md create mode 100644 .changeset/system-config-storage.md create mode 100644 packages/aio-commerce-lib-app/test/unit/actions/association.test.ts create mode 100644 packages/aio-commerce-lib-app/test/unit/errors/app-not-associated-error.test.ts create mode 100644 packages/aio-commerce-lib-app/test/unit/index.test.ts create mode 100644 packages/aio-commerce-lib-app/test/unit/modules/association/association-repository.test.ts diff --git a/.changeset/commerce-instance-helpers.md b/.changeset/commerce-instance-helpers.md new file mode 100644 index 000000000..5097823a0 --- /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 instance data, and `getCommerceClient` returns a ready-to-use Commerce HTTP client. diff --git a/.changeset/system-config-storage.md b/.changeset/system-config-storage.md new file mode 100644 index 000000000..795e44482 --- /dev/null +++ b/.changeset/system-config-storage.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-config": minor +--- + +Add ability to store and retrieve generic SDK system configuration via the new `/system` subpath export. diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index 87f821623..cb015fc5d 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -8,6 +8,7 @@ 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 SDK Configuration**: Generate and manage the runtime action that powers the `commerce/backend-ui/1` extension point. +- **Association Helpers**: Retrieve the Commerce instance the app is associated with from any runtime action via `getCommerceClient` and `getCommerceInstance`. ## Reference @@ -711,6 +712,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(params)` — returns a ready-to-use + [`AdobeCommerceHttpClient`](../../aio-commerce-lib-api/docs/usage.md). Use this when you + need to call the Commerce API. +- `getCommerceInstance(params)` — 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"; + +export async function main(params) { + const client = await getCommerceClient(params); + const products = await client.get("rest/V1/products").json(); +} +``` + +#### Low-level pattern — get the raw instance data + +```ts +import { getCommerceInstance } from "@adobe/aio-commerce-lib-app"; + +export async function main(params) { + const instance = await getCommerceInstance(params); + + // 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 { + AppNotAssociatedError, + getCommerceClient, +} from "@adobe/aio-commerce-lib-app"; + +export async function main(params) { + try { + const client = await getCommerceClient(params); + return { + statusCode: 200, + body: await client.get("rest/V1/products").json(), + }; + } catch (error) { + if (error instanceof AppNotAssociatedError) { + return { + statusCode: 400, + body: { error: "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. You do not need to deploy or wire anything +extra. + ## Best Practices 1. **Use `defineConfig` for type safety** - Get autocompletion and type checking in your IDE 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..33ab4c583 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/actions/association.test.ts @@ -0,0 +1,141 @@ +/* + * 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("#modules/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 200 with the saved values", 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: 200, + body: { + baseUrl: "https://example.com", + env: "paas", + }, + }); + }); + + 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: 200, + }); + }); + + 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 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(); + }); + }); + + 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, + }); + }); + }); +}); 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/index.test.ts b/packages/aio-commerce-lib-app/test/unit/index.test.ts new file mode 100644 index 000000000..9873521d3 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/index.test.ts @@ -0,0 +1,136 @@ +/* + * 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, + mockResolveCommerceHttpClientParams, + MockAdobeCommerceHttpClient, +} = vi.hoisted(() => ({ + mockGetAssociationData: vi.fn(), + mockResolveCommerceHttpClientParams: vi.fn(), + MockAdobeCommerceHttpClient: vi.fn(), +})); + +vi.mock("#modules/association/association-repository", () => ({ + getAssociationData: mockGetAssociationData, +})); + +vi.mock("@adobe/aio-commerce-lib-api", () => ({ + resolveCommerceHttpClientParams: mockResolveCommerceHttpClientParams, + AdobeCommerceHttpClient: MockAdobeCommerceHttpClient, +})); + +import { + AppNotAssociatedError, + getCommerceClient, + getCommerceInstance, +} from "#index"; +import { createRuntimeActionParams } from "#test/fixtures/actions"; + +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(createRuntimeActionParams()); + + 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(createRuntimeActionParams()); + + expect(result).toEqual(data); + }); + + test("throws AppNotAssociatedError when no data is stored", async () => { + mockGetAssociationData.mockResolvedValue(null); + + await expect( + getCommerceInstance(createRuntimeActionParams()), + ).rejects.toBeInstanceOf(AppNotAssociatedError); + }); +}); + +describe("getCommerceClient", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns an AdobeCommerceHttpClient when association data is present", async () => { + const data = { + baseUrl: "https://example.com", + env: "paas" as const, + }; + const resolvedParams = { + auth: {}, + config: { baseUrl: data.baseUrl, flavor: "paas" }, + }; + mockGetAssociationData.mockResolvedValue(data); + mockResolveCommerceHttpClientParams.mockReturnValue(resolvedParams); + + const result = await getCommerceClient(createRuntimeActionParams()); + + expect(mockResolveCommerceHttpClientParams).toHaveBeenCalledWith( + expect.objectContaining({ + AIO_COMMERCE_API_BASE_URL: data.baseUrl, + AIO_COMMERCE_API_FLAVOR: data.env, + }), + ); + expect(MockAdobeCommerceHttpClient).toHaveBeenCalledWith(resolvedParams); + expect(result).toBeInstanceOf(MockAdobeCommerceHttpClient); + }); + + test("passes saas env to resolveCommerceHttpClientParams", async () => { + const data = { + baseUrl: "https://saas.example.com", + env: "saas" as const, + }; + mockGetAssociationData.mockResolvedValue(data); + mockResolveCommerceHttpClientParams.mockReturnValue({}); + + await getCommerceClient(createRuntimeActionParams()); + + expect(mockResolveCommerceHttpClientParams).toHaveBeenCalledWith( + expect.objectContaining({ + AIO_COMMERCE_API_BASE_URL: data.baseUrl, + AIO_COMMERCE_API_FLAVOR: "saas", + }), + ); + }); + + test("throws AppNotAssociatedError when no data is stored", async () => { + mockGetAssociationData.mockResolvedValue(null); + + await expect( + getCommerceClient(createRuntimeActionParams()), + ).rejects.toBeInstanceOf(AppNotAssociatedError); + + expect(mockResolveCommerceHttpClientParams).not.toHaveBeenCalled(); + expect(MockAdobeCommerceHttpClient).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/aio-commerce-lib-app/test/unit/modules/association/association-repository.test.ts b/packages/aio-commerce-lib-app/test/unit/modules/association/association-repository.test.ts new file mode 100644 index 000000000..4ec2d46f5 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/modules/association/association-repository.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { mockGetSystemConfigByKey, mockSetSystemConfigByKey } = vi.hoisted( + () => ({ + mockGetSystemConfigByKey: vi.fn(), + mockSetSystemConfigByKey: vi.fn(), + }), +); + +vi.mock("@adobe/aio-commerce-lib-config/system", () => ({ + getSystemConfigByKey: mockGetSystemConfigByKey, + setSystemConfigByKey: mockSetSystemConfigByKey, +})); + +import { + clearAssociationData, + getAssociationData, + setAssociationData, +} from "#modules/association/association-repository"; + +describe("association-repository", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("setAssociationData", () => { + test("writes the data under the system.association key", async () => { + const data = { + baseUrl: "https://example.com", + env: "paas" as const, + }; + await setAssociationData(data); + + expect(mockSetSystemConfigByKey).toHaveBeenCalledWith( + "system.association", + data, + ); + }); + }); + + describe("getAssociationData", () => { + test("reads from the system.association key", async () => { + const data = { + baseUrl: "https://example.com", + env: "saas" as const, + }; + mockGetSystemConfigByKey.mockResolvedValue(data); + + const result = await getAssociationData(); + + expect(mockGetSystemConfigByKey).toHaveBeenCalledWith( + "system.association", + ); + expect(result).toEqual(data); + }); + + test("returns null when no data is stored", async () => { + mockGetSystemConfigByKey.mockResolvedValue(null); + + const result = await getAssociationData(); + + expect(result).toBeNull(); + }); + }); + + describe("clearAssociationData", () => { + test("clears the system.association key by setting it to null", async () => { + await clearAssociationData(); + + expect(mockSetSystemConfigByKey).toHaveBeenCalledWith( + "system.association", + null, + ); + }); + }); +}); From 46ed9327dc7501b5a249f76b842a926a3dd661e5 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 10 Jun 2026 11:38:06 -0500 Subject: [PATCH 18/46] CEXT-6160: clarify association adoption docs for new vs existing apps --- packages/aio-commerce-lib-app/docs/usage.md | 26 +++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index d1920ed59..75e0fbf72 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -860,8 +860,30 @@ export async function main(params) { 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. You do not need to deploy or wire anything -extra. +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 From f54668cfe95c88ff37272f0630c64f42e4d56261 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 10 Jun 2026 13:57:59 -0500 Subject: [PATCH 19/46] CEXT-6160: resolve merge conflict in lib-app usage.md --- packages/aio-commerce-lib-app/docs/usage.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index 75e0fbf72..2fec4d5d5 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -7,12 +7,9 @@ The `@adobe/aio-commerce-lib-app` library provides: - **App Configuration**: Define, validate and read/parse configurations for Adobe Commerce App Builder applications - **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. - <<<<<<< HEAD -- **Admin UI SDK Configuration**: Generate and manage the runtime action that powers the `commerce/backend-ui/1` extension point. -- # **Association Helpers**: Retrieve the Commerce instance the app is associated with from any runtime action via `getCommerceClient` and `getCommerceInstance`. - **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, menus, view buttons, and custom fees will follow. - **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. - > > > > > > > 226e62e8602076e7449ad22b2eb74262c31b0872 +- **Association Helpers**: Retrieve the Commerce instance the app is associated with from any runtime action via `getCommerceClient` and `getCommerceInstance`. ## Reference From 6ab289f996d40304be6ba201b2f78e0c71fe0a8b Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Thu, 11 Jun 2026 10:07:50 -0500 Subject: [PATCH 20/46] CEXT-6160: validate commerceBaseUrl as a URL in association schema --- .../aio-commerce-lib-app/docs/openapi.json | 5 +++-- .../source/actions/association/schema.ts | 7 ++++++- .../test/unit/actions/association.test.ts | 20 +++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/aio-commerce-lib-app/docs/openapi.json b/packages/aio-commerce-lib-app/docs/openapi.json index 4b04d321c..23a054bea 100644 --- a/packages/aio-commerce-lib-app/docs/openapi.json +++ b/packages/aio-commerce-lib-app/docs/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "App Management API", "description": "REST API for managing Adobe Commerce App Builder apps: resolving app configuration, managing configuration values, running the installation and uninstallation lifecycle, exposing the Admin UI SDK registration, and managing the scope tree.", - "version": "1.1.0", + "version": "1.1.1", "contact": {}, "license": { "identifier": "Apache-2.0", @@ -2694,7 +2694,8 @@ "properties": { "commerceBaseUrl": { "description": "Commerce API base URL of the associated instance.", - "type": "string" + "type": "string", + "format": "uri" }, "commerceEnv": { "description": "Deployment type of the associated Commerce instance.", diff --git a/packages/aio-commerce-lib-app/source/actions/association/schema.ts b/packages/aio-commerce-lib-app/source/actions/association/schema.ts index 8a160ac86..d2ecb7456 100644 --- a/packages/aio-commerce-lib-app/source/actions/association/schema.ts +++ b/packages/aio-commerce-lib-app/source/actions/association/schema.ts @@ -14,6 +14,11 @@ import * as v from "valibot"; /** Request body for POST / — store association data. */ export const AssociationRequestBodySchema = v.object({ - commerceBaseUrl: v.string(), + commerceBaseUrl: v.pipe( + v.string(), + v.url( + "The 'commerceBaseUrl' field must be a valid absolute URL (e.g., 'https://my-store.example.com')", + ), + ), commerceEnv: v.picklist(["saas", "paas"]), }); 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 index 33ab4c583..a2f70936f 100644 --- a/packages/aio-commerce-lib-app/test/unit/actions/association.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/actions/association.test.ts @@ -101,6 +101,26 @@ describe("associationRuntimeAction", () => { 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({ From 305860ad0ec46868b148d94fca8acb773831e1bf Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Thu, 11 Jun 2026 10:09:47 -0500 Subject: [PATCH 21/46] CEXT-6160: document system-repository helpers and read files directly --- .../configuration/system/system-repository.ts | 32 +++++++++++++++---- .../system/system-repository.test.ts | 7 ++-- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts b/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts index 5a8d23e11..7db52732d 100644 --- a/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts +++ b/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts @@ -17,10 +17,15 @@ import { getSharedFiles, getSharedState } from "#utils/repository"; const SYSTEM_FILES_PREFIX = "system/"; +/** Maps a system config key to its path in `aio-lib-files`. */ function getSystemFilePath(key: string): string { return `${SYSTEM_FILES_PREFIX}${key}.json`; } +/** + * Reads a raw payload from the `aio-lib-state` cache. Returns `null` on a cache + * miss or any read failure, so callers can transparently fall back to files. + */ async function readFromCache(key: string): Promise { try { const state = await getSharedState(); @@ -31,6 +36,10 @@ async function readFromCache(key: string): Promise { } } +/** + * Writes a payload to the `aio-lib-state` cache. Cache failures are swallowed + * (logged at debug) since `aio-lib-files` remains the source of truth. + */ async function writeToCache(key: string, payload: string): Promise { const logger = getLogger("@adobe/aio-commerce-lib-config:system-repository"); try { @@ -44,6 +53,10 @@ async function writeToCache(key: string, payload: string): Promise { } } +/** + * Removes a key from the `aio-lib-state` cache. Failures are swallowed (logged + * at debug) since the entry is also cleared from `aio-lib-files`. + */ async function deleteFromCache(key: string): Promise { const logger = getLogger("@adobe/aio-commerce-lib-config:system-repository"); try { @@ -57,17 +70,14 @@ async function deleteFromCache(key: string): Promise { } } +/** + * Reads a raw payload from `aio-lib-files`, the persistent source of truth. + * Returns `null` when the file does not exist or cannot be read. + */ async function readFromFiles(key: string): Promise { try { const files = await getSharedFiles(); const filePath = getSystemFilePath(key); - const filesList = await files.list(SYSTEM_FILES_PREFIX); - const fileObject = filesList.find((file) => file.name === filePath); - - if (!fileObject) { - return null; - } - const content = await files.read(filePath); return content ? content.toString("utf8") : null; } catch { @@ -75,12 +85,20 @@ async function readFromFiles(key: string): Promise { } } +/** + * Writes a payload to `aio-lib-files`, the persistent source of truth. Failures + * propagate so the caller can surface a failed write. + */ async function writeToFiles(key: string, payload: string): Promise { const files = await getSharedFiles(); const filePath = getSystemFilePath(key); await files.write(filePath, payload); } +/** + * Removes a key's file from `aio-lib-files`. Failures are swallowed (logged at + * debug) so clearing a non-existent entry is a no-op. + */ async function deleteFromFiles(key: string): Promise { const logger = getLogger("@adobe/aio-commerce-lib-config:system-repository"); try { diff --git a/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts b/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts index db9cd5edc..8c5e02414 100644 --- a/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts +++ b/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts @@ -103,9 +103,6 @@ describe("system-repository", () => { test("falls back to files when cache miss, then re-caches", async () => { const data = { baseUrl: "https://example.com", env: "paas" }; mockState.get.mockResolvedValue({ value: null }); - mockFiles.list.mockResolvedValue([ - { name: "system/system.association.json" }, - ]); mockFiles.read.mockResolvedValue(Buffer.from(JSON.stringify(data))); const { getSystemConfigByKey } = await import( @@ -126,7 +123,7 @@ describe("system-repository", () => { test("returns null when not found in cache or files", async () => { mockState.get.mockResolvedValue({ value: null }); - mockFiles.list.mockResolvedValue([]); + mockFiles.read.mockRejectedValue(new Error("file not found")); const { getSystemConfigByKey } = await import( "#modules/configuration/system/system-repository" @@ -153,7 +150,7 @@ describe("system-repository", () => { test("returns null when files read throws", async () => { mockState.get.mockResolvedValue({ value: null }); - mockFiles.list.mockRejectedValue(new Error("files error")); + mockFiles.read.mockRejectedValue(new Error("files error")); const { getSystemConfigByKey } = await import( "#modules/configuration/system/system-repository" From fb2dfc47fea42c757d59c77e0fb6bf8ba71dd6ac Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Fri, 12 Jun 2026 11:44:20 -0500 Subject: [PATCH 22/46] CEXT-6160: move commerce helpers out of index barrel --- packages/aio-commerce-lib-app/source/index.ts | 82 +---------------- .../modules/association/commerce-instance.ts | 89 +++++++++++++++++++ 2 files changed, 93 insertions(+), 78 deletions(-) create mode 100644 packages/aio-commerce-lib-app/source/modules/association/commerce-instance.ts diff --git a/packages/aio-commerce-lib-app/source/index.ts b/packages/aio-commerce-lib-app/source/index.ts index d5cacd9c3..ae4198b6a 100644 --- a/packages/aio-commerce-lib-app/source/index.ts +++ b/packages/aio-commerce-lib-app/source/index.ts @@ -18,85 +18,11 @@ * @packageDocumentation */ -import { - AdobeCommerceHttpClient, - resolveCommerceHttpClientParams, -} from "@adobe/aio-commerce-lib-api"; - -import { AppNotAssociatedError } from "./errors/app-not-associated-error"; -import { getAssociationData } from "./modules/association/association-repository"; - -import type { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; -import type { AssociatedCommerceInstance } from "./modules/association/types"; - // biome-ignore lint/performance/noBarrelFile: export as part of the Public API export { AppNotAssociatedError } from "./errors/app-not-associated-error"; +export { + getCommerceClient, + getCommerceInstance, +} from "./modules/association/commerce-instance"; export type { AssociatedCommerceInstance } from "./modules/association/types"; - -/** - * Returns the Commerce instance this app is currently associated with. - * - * @param _params - The standard params object every runtime action receives. - * @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(params) { - * const instance = await getCommerceInstance(params); - * - * // instance.baseUrl — e.g. "https://my-store.example.com" - * // instance.env — "saas" | "paas" - * } - * ``` - */ -export async function getCommerceInstance( - _params: RuntimeActionParams, -): 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. - * - * Internally calls {@link getCommerceInstance} and combines the stored - * `baseUrl` and `env` with the auth credentials present in `params` to build - * the client. - * - * @param params - The standard params object every runtime action receives. - * @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"; - * - * export async function main(params) { - * const client = await getCommerceClient(params); - * const products = await client.get("rest/V1/products").json(); - * } - * ``` - */ -export async function getCommerceClient( - params: RuntimeActionParams, -): Promise { - const instance = await getCommerceInstance(params); - - const httpClientParams = resolveCommerceHttpClientParams({ - ...params, - AIO_COMMERCE_API_BASE_URL: instance.baseUrl, - AIO_COMMERCE_API_FLAVOR: instance.env, - }); - - return new AdobeCommerceHttpClient(httpClientParams); -} diff --git a/packages/aio-commerce-lib-app/source/modules/association/commerce-instance.ts b/packages/aio-commerce-lib-app/source/modules/association/commerce-instance.ts new file mode 100644 index 000000000..e283990d2 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/modules/association/commerce-instance.ts @@ -0,0 +1,89 @@ +/* + * 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, + resolveCommerceHttpClientParams, +} from "@adobe/aio-commerce-lib-api"; + +import { AppNotAssociatedError } from "../../errors/app-not-associated-error"; +import { getAssociationData } from "./association-repository"; + +import type { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; +import type { AssociatedCommerceInstance } from "./types"; + +/** + * Returns the Commerce instance this app is currently associated with. + * + * @param _params - The standard params object every runtime action receives. + * @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(params) { + * const instance = await getCommerceInstance(params); + * + * // instance.baseUrl — e.g. "https://my-store.example.com" + * // instance.env — "saas" | "paas" + * } + * ``` + */ +export async function getCommerceInstance( + _params: RuntimeActionParams, +): 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. + * + * Internally calls {@link getCommerceInstance} and combines the stored + * `baseUrl` and `env` with the auth credentials present in `params` to build + * the client. + * + * @param params - The standard params object every runtime action receives. + * @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"; + * + * export async function main(params) { + * const client = await getCommerceClient(params); + * const products = await client.get("rest/V1/products").json(); + * } + * ``` + */ +export async function getCommerceClient( + params: RuntimeActionParams, +): Promise { + const instance = await getCommerceInstance(params); + + const httpClientParams = resolveCommerceHttpClientParams({ + ...params, + AIO_COMMERCE_API_BASE_URL: instance.baseUrl, + AIO_COMMERCE_API_FLAVOR: instance.env, + }); + + return new AdobeCommerceHttpClient(httpClientParams); +} From 80a75267bf44b7b6f2e204bafdbd09f49d1f379f Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Fri, 12 Jun 2026 11:46:24 -0500 Subject: [PATCH 23/46] CEXT-6160: test 500 path when association storage fails --- .../test/unit/actions/association.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) 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 index a2f70936f..16aa3f23c 100644 --- a/packages/aio-commerce-lib-app/test/unit/actions/association.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/actions/association.test.ts @@ -139,6 +139,32 @@ describe("associationRuntimeAction", () => { }); 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 /", () => { @@ -157,5 +183,27 @@ describe("associationRuntimeAction", () => { 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(); + }); }); }); From 8b61c1e23a9f716e35829bb6c4ca625e5246ac6d Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Fri, 12 Jun 2026 11:48:38 -0500 Subject: [PATCH 24/46] CEXT-6160: reuse CommerceSdkErrorBaseOptions in AppNotAssociatedError --- .../source/errors/app-not-associated-error.ts | 8 +++----- packages/aio-commerce-lib-core/source/error/index.ts | 5 ++++- 2 files changed, 7 insertions(+), 6 deletions(-) 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 index dbef639c5..af89bb495 100644 --- 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 @@ -12,13 +12,11 @@ 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."; -type AppNotAssociatedErrorOptions = ErrorOptions & { - traceId?: string; -}; - /** * Thrown when a runtime action calls `getCommerceInstance` or `getCommerceClient` * but the app has no stored association data — for example, when the app has @@ -45,7 +43,7 @@ type AppNotAssociatedErrorOptions = ErrorOptions & { export class AppNotAssociatedError extends CommerceSdkErrorBase { public constructor( message: string = DEFAULT_MESSAGE, - options?: AppNotAssociatedErrorOptions, + options?: CommerceSdkErrorBaseOptions, ) { super(message, options); } 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"; From 48470868d2fcfefc82b664724b5d2a4075d72011 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Fri, 12 Jun 2026 11:49:40 -0500 Subject: [PATCH 25/46] CEXT-6160: document 401/403 on association routes --- packages/aio-commerce-lib-app/docs/openapi.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/aio-commerce-lib-app/docs/openapi.json b/packages/aio-commerce-lib-app/docs/openapi.json index 23a054bea..c44c1640c 100644 --- a/packages/aio-commerce-lib-app/docs/openapi.json +++ b/packages/aio-commerce-lib-app/docs/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "App Management API", "description": "REST API for managing Adobe Commerce App Builder apps: resolving app configuration, managing configuration values, running the installation and uninstallation lifecycle, exposing the Admin UI SDK registration, and managing the scope tree.", - "version": "1.1.1", + "version": "1.1.0", "contact": {}, "license": { "identifier": "Apache-2.0", @@ -2739,6 +2739,12 @@ } } }, + "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": { @@ -2771,6 +2777,12 @@ "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": { From 90f24916c83093843e8aec867de6505945b294b1 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Fri, 12 Jun 2026 11:50:34 -0500 Subject: [PATCH 26/46] CEXT-6160: use response presets and unwrap prose in usage.md --- packages/aio-commerce-lib-app/docs/usage.md | 52 ++++++--------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index 2fec4d5d5..0fb4ab554 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -784,22 +784,14 @@ 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. +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(params)` — returns a ready-to-use - [`AdobeCommerceHttpClient`](../../aio-commerce-lib-api/docs/usage.md). Use this when you - need to call the Commerce API. -- `getCommerceInstance(params)` — returns the raw `{ baseUrl, env }`. Use this when you only - need the metadata (e.g. for logging or building a custom client). +- `getCommerceClient(params)` — returns a ready-to-use [`AdobeCommerceHttpClient`](../../aio-commerce-lib-api/docs/usage.md). Use this when you need to call the Commerce API. +- `getCommerceInstance(params)` — 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. +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 @@ -827,10 +819,10 @@ export async function main(params) { #### 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`: +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, @@ -839,48 +831,32 @@ import { export async function main(params) { try { const client = await getCommerceClient(params); - return { - statusCode: 200, - body: await client.get("rest/V1/products").json(), - }; + return ok({ body: await client.get("rest/V1/products").json() }); } catch (error) { if (error instanceof AppNotAssociatedError) { - return { - statusCode: 400, - body: { error: "App is not associated with a Commerce instance." }, - }; + 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. +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: +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`. +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. +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 From 0a4de5c1c1818360dec8f597dcc5e4c210195317 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Fri, 12 Jun 2026 11:52:31 -0500 Subject: [PATCH 27/46] CEXT-6160: invalidate system-config cache on write failure --- .../configuration/system/system-repository.ts | 49 ++++++++++++++--- .../system/system-repository.test.ts | 55 ++++++++++++++++++- 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts b/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts index 7db52732d..cfd5dd66b 100644 --- a/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts +++ b/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts @@ -22,6 +22,27 @@ function getSystemFilePath(key: string): string { return `${SYSTEM_FILES_PREFIX}${key}.json`; } +/** + * Parses a stored JSON payload, returning `undefined` instead of throwing when + * the payload is corrupt. A malformed entry must never crash a read: callers + * treat `undefined` as unusable and fall back to the source of truth. No JSON + * text parses to `undefined`, so it is an unambiguous failure sentinel. + */ +function safeJsonParse(raw: string): T | undefined { + try { + return JSON.parse(raw) as T; + } catch (error) { + const logger = getLogger( + "@adobe/aio-commerce-lib-config:system-repository", + ); + logger.debug( + "Failed to parse stored system config payload:", + error instanceof Error ? error.message : String(error), + ); + return; + } +} + /** * Reads a raw payload from the `aio-lib-state` cache. Returns `null` on a cache * miss or any read failure, so callers can transparently fall back to files. @@ -39,6 +60,10 @@ async function readFromCache(key: string): Promise { /** * Writes a payload to the `aio-lib-state` cache. Cache failures are swallowed * (logged at debug) since `aio-lib-files` remains the source of truth. + * + * On a write failure the prior entry is invalidated rather than left in place: + * the cache-first read path trusts a cache hit unconditionally, so a stale + * value must never be allowed to shadow the freshly persisted source of truth. */ async function writeToCache(key: string, payload: string): Promise { const logger = getLogger("@adobe/aio-commerce-lib-config:system-repository"); @@ -47,9 +72,10 @@ async function writeToCache(key: string, payload: string): Promise { await state.put(key, payload); } catch (error) { logger.debug( - "Failed to cache system config:", + "Failed to cache system config; invalidating cache entry:", error instanceof Error ? error.message : String(error), ); + await deleteFromCache(key); } } @@ -117,17 +143,17 @@ async function deleteFromFiles(key: string): Promise { * Stores or clears a system configuration value by key. * * Persists the value to `aio-lib-files` (source of truth) and writes it to - * `aio-lib-state` as a performance cache. Passing `null` clears the entry from - * both storage layers. + * `aio-lib-state` as a performance cache. Passing `null` or `undefined` clears + * the entry from both storage layers. * * @param key - The system configuration key (e.g. `"system.association"`). - * @param value - The value to store, or `null` to clear the entry. + * @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) { + if (value === null || value === undefined) { await deleteFromFiles(key); await deleteFromCache(key); return; @@ -151,7 +177,11 @@ export async function setSystemConfigByKey( export async function getSystemConfigByKey(key: string): Promise { const cached = await readFromCache(key); if (cached !== null) { - return JSON.parse(cached) as T; + const parsed = safeJsonParse(cached); + if (parsed !== undefined) { + return parsed; + } + // Corrupt cache entry: fall through to the source of truth. } const persisted = await readFromFiles(key); @@ -159,7 +189,12 @@ export async function getSystemConfigByKey(key: string): Promise { return null; } + const parsed = safeJsonParse(persisted); + if (parsed === undefined) { + return null; + } + // Re-cache for subsequent reads await writeToCache(key, persisted); - return JSON.parse(persisted) as T; + return parsed; } diff --git a/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts b/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts index 8c5e02414..dad2dac5a 100644 --- a/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts +++ b/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts @@ -70,7 +70,7 @@ describe("system-repository", () => { expect(mockState.put).not.toHaveBeenCalled(); }); - test("does not throw if cache write fails", async () => { + 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/system-repository" @@ -80,7 +80,25 @@ describe("system-repository", () => { setSystemConfigByKey("system.association", { 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("system.association"); + }); + + test("clears both files and state when value is undefined", async () => { + const { setSystemConfigByKey } = await import( + "#modules/configuration/system/system-repository" + ); + + await setSystemConfigByKey("system.association", undefined); + + expect(mockFiles.delete).toHaveBeenCalledWith( + "system/system.association.json", + ); + expect(mockState.delete).toHaveBeenCalledWith("system.association"); + expect(mockFiles.write).not.toHaveBeenCalled(); + expect(mockState.put).not.toHaveBeenCalled(); }); }); @@ -160,5 +178,40 @@ describe("system-repository", () => { 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.mockResolvedValue({ value: "{not valid json" }); + mockFiles.read.mockResolvedValue(Buffer.from(JSON.stringify(data))); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system/system-repository" + ); + + const result = await getSystemConfigByKey("system.association"); + + expect(result).toEqual(data); + expect(mockFiles.read).toHaveBeenCalledWith( + "system/system.association.json", + ); + expect(mockState.put).toHaveBeenCalledWith( + "system.association", + JSON.stringify(data), + ); + }); + + test("returns null when the persisted file holds corrupt JSON", async () => { + mockState.get.mockResolvedValue({ value: null }); + mockFiles.read.mockResolvedValue(Buffer.from("{not valid json")); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system/system-repository" + ); + + const result = await getSystemConfigByKey("system.association"); + + expect(result).toBeNull(); + expect(mockState.put).not.toHaveBeenCalled(); + }); }); }); From b2409d94ca0d2593b492571afe13c027dcb35c9c Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 00:54:13 -0500 Subject: [PATCH 28/46] CEXT-6160: move runtime helpers to access module --- .../commerce-instance.ts | 53 +++++++------- packages/aio-commerce-lib-app/source/index.ts | 4 +- .../test/unit/index.test.ts | 71 ++++++++----------- 3 files changed, 55 insertions(+), 73 deletions(-) rename packages/aio-commerce-lib-app/source/{modules/association => access}/commerce-instance.ts (55%) diff --git a/packages/aio-commerce-lib-app/source/modules/association/commerce-instance.ts b/packages/aio-commerce-lib-app/source/access/commerce-instance.ts similarity index 55% rename from packages/aio-commerce-lib-app/source/modules/association/commerce-instance.ts rename to packages/aio-commerce-lib-app/source/access/commerce-instance.ts index e283990d2..ebc04e833 100644 --- a/packages/aio-commerce-lib-app/source/modules/association/commerce-instance.ts +++ b/packages/aio-commerce-lib-app/source/access/commerce-instance.ts @@ -10,21 +10,17 @@ * governing permissions and limitations under the License. */ -import { - AdobeCommerceHttpClient, - resolveCommerceHttpClientParams, -} from "@adobe/aio-commerce-lib-api"; +import { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; -import { AppNotAssociatedError } from "../../errors/app-not-associated-error"; -import { getAssociationData } from "./association-repository"; +import { AppNotAssociatedError } from "../errors/app-not-associated-error"; +import { getAssociationData } from "../modules/association/association-repository"; -import type { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; -import type { AssociatedCommerceInstance } from "./types"; +import type { CommerceHttpClientParams } from "@adobe/aio-commerce-lib-api"; +import type { AssociatedCommerceInstance } from "../modules/association/types"; /** * Returns the Commerce instance this app is currently associated with. * - * @param _params - The standard params object every runtime action receives. * @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. @@ -33,17 +29,15 @@ import type { AssociatedCommerceInstance } from "./types"; * ```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" * } * ``` */ -export async function getCommerceInstance( - _params: RuntimeActionParams, -): Promise { +export async function getCommerceInstance(): Promise { const instance = await getAssociationData(); if (instance === null) { throw new AppNotAssociatedError(); @@ -55,11 +49,13 @@ export async function getCommerceInstance( * Returns an initialised `AdobeCommerceHttpClient` for the Commerce instance * this app is currently associated with. * - * Internally calls {@link getCommerceInstance} and combines the stored - * `baseUrl` and `env` with the auth credentials present in `params` to build - * the client. + * The base URL and flavor come from the stored association data + * ({@link getCommerceInstance}); only the auth credentials are supplied by the + * caller, already resolved. Resolve them outside with `resolveAuthParams` from + * `@adobe/aio-commerce-lib-auth` so this helper stays focused on building the + * client for the associated instance. * - * @param params - The standard params object every runtime action receives. + * @param auth - Resolved Commerce auth credentials. * @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. @@ -67,23 +63,24 @@ export async function getCommerceInstance( * @example * ```ts * import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; + * import { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; * * export async function main(params) { - * const client = await getCommerceClient(params); + * const client = await getCommerceClient(resolveAuthParams(params)); * const products = await client.get("rest/V1/products").json(); * } * ``` */ export async function getCommerceClient( - params: RuntimeActionParams, + auth: CommerceHttpClientParams["auth"], ): Promise { - const instance = await getCommerceInstance(params); - - const httpClientParams = resolveCommerceHttpClientParams({ - ...params, - AIO_COMMERCE_API_BASE_URL: instance.baseUrl, - AIO_COMMERCE_API_FLAVOR: instance.env, - }); + const instance = await getCommerceInstance(); - return new AdobeCommerceHttpClient(httpClientParams); + // `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 }, + } as CommerceHttpClientParams); } diff --git a/packages/aio-commerce-lib-app/source/index.ts b/packages/aio-commerce-lib-app/source/index.ts index ae4198b6a..ec4cd4d5d 100644 --- a/packages/aio-commerce-lib-app/source/index.ts +++ b/packages/aio-commerce-lib-app/source/index.ts @@ -19,10 +19,10 @@ */ // biome-ignore lint/performance/noBarrelFile: export as part of the Public API -export { AppNotAssociatedError } from "./errors/app-not-associated-error"; export { getCommerceClient, getCommerceInstance, -} from "./modules/association/commerce-instance"; +} from "./access/commerce-instance"; +export { AppNotAssociatedError } from "./errors/app-not-associated-error"; export type { AssociatedCommerceInstance } from "./modules/association/types"; diff --git a/packages/aio-commerce-lib-app/test/unit/index.test.ts b/packages/aio-commerce-lib-app/test/unit/index.test.ts index 9873521d3..d64f1c94c 100644 --- a/packages/aio-commerce-lib-app/test/unit/index.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/index.test.ts @@ -12,22 +12,18 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -const { - mockGetAssociationData, - mockResolveCommerceHttpClientParams, - MockAdobeCommerceHttpClient, -} = vi.hoisted(() => ({ - mockGetAssociationData: vi.fn(), - mockResolveCommerceHttpClientParams: vi.fn(), - MockAdobeCommerceHttpClient: vi.fn(), -})); +const { mockGetAssociationData, MockAdobeCommerceHttpClient } = vi.hoisted( + () => ({ + mockGetAssociationData: vi.fn(), + MockAdobeCommerceHttpClient: vi.fn(), + }), +); vi.mock("#modules/association/association-repository", () => ({ getAssociationData: mockGetAssociationData, })); vi.mock("@adobe/aio-commerce-lib-api", () => ({ - resolveCommerceHttpClientParams: mockResolveCommerceHttpClientParams, AdobeCommerceHttpClient: MockAdobeCommerceHttpClient, })); @@ -36,7 +32,8 @@ import { getCommerceClient, getCommerceInstance, } from "#index"; -import { createRuntimeActionParams } from "#test/fixtures/actions"; + +const auth = { strategy: "ims" } as never; describe("getCommerceInstance", () => { beforeEach(() => { @@ -50,7 +47,7 @@ describe("getCommerceInstance", () => { }; mockGetAssociationData.mockResolvedValue(data); - const result = await getCommerceInstance(createRuntimeActionParams()); + const result = await getCommerceInstance(); expect(result).toEqual(data); }); @@ -62,7 +59,7 @@ describe("getCommerceInstance", () => { }; mockGetAssociationData.mockResolvedValue(data); - const result = await getCommerceInstance(createRuntimeActionParams()); + const result = await getCommerceInstance(); expect(result).toEqual(data); }); @@ -70,9 +67,9 @@ describe("getCommerceInstance", () => { test("throws AppNotAssociatedError when no data is stored", async () => { mockGetAssociationData.mockResolvedValue(null); - await expect( - getCommerceInstance(createRuntimeActionParams()), - ).rejects.toBeInstanceOf(AppNotAssociatedError); + await expect(getCommerceInstance()).rejects.toBeInstanceOf( + AppNotAssociatedError, + ); }); }); @@ -81,56 +78,44 @@ describe("getCommerceClient", () => { vi.clearAllMocks(); }); - test("returns an AdobeCommerceHttpClient when association data is present", async () => { + test("builds the client from the stored instance and supplied auth", async () => { const data = { baseUrl: "https://example.com", env: "paas" as const, }; - const resolvedParams = { - auth: {}, - config: { baseUrl: data.baseUrl, flavor: "paas" }, - }; mockGetAssociationData.mockResolvedValue(data); - mockResolveCommerceHttpClientParams.mockReturnValue(resolvedParams); - const result = await getCommerceClient(createRuntimeActionParams()); + const result = await getCommerceClient(auth); - expect(mockResolveCommerceHttpClientParams).toHaveBeenCalledWith( - expect.objectContaining({ - AIO_COMMERCE_API_BASE_URL: data.baseUrl, - AIO_COMMERCE_API_FLAVOR: data.env, - }), - ); - expect(MockAdobeCommerceHttpClient).toHaveBeenCalledWith(resolvedParams); + expect(MockAdobeCommerceHttpClient).toHaveBeenCalledWith({ + auth, + config: { baseUrl: data.baseUrl, flavor: "paas" }, + }); expect(result).toBeInstanceOf(MockAdobeCommerceHttpClient); }); - test("passes saas env to resolveCommerceHttpClientParams", async () => { + test("passes the saas flavor from the stored env", async () => { const data = { baseUrl: "https://saas.example.com", env: "saas" as const, }; mockGetAssociationData.mockResolvedValue(data); - mockResolveCommerceHttpClientParams.mockReturnValue({}); - await getCommerceClient(createRuntimeActionParams()); + await getCommerceClient(auth); - expect(mockResolveCommerceHttpClientParams).toHaveBeenCalledWith( - expect.objectContaining({ - AIO_COMMERCE_API_BASE_URL: data.baseUrl, - AIO_COMMERCE_API_FLAVOR: "saas", - }), - ); + expect(MockAdobeCommerceHttpClient).toHaveBeenCalledWith({ + auth, + config: { baseUrl: data.baseUrl, flavor: "saas" }, + }); }); test("throws AppNotAssociatedError when no data is stored", async () => { mockGetAssociationData.mockResolvedValue(null); - await expect( - getCommerceClient(createRuntimeActionParams()), - ).rejects.toBeInstanceOf(AppNotAssociatedError); + await expect(getCommerceClient(auth)).rejects.toBeInstanceOf( + AppNotAssociatedError, + ); - expect(mockResolveCommerceHttpClientParams).not.toHaveBeenCalled(); expect(MockAdobeCommerceHttpClient).not.toHaveBeenCalled(); }); }); From bf311449457df68cbf0c4a45c0526794785bd2de Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 00:55:39 -0500 Subject: [PATCH 29/46] CEXT-6160: make getCommerceClient composable, drop unused param --- .changeset/commerce-instance-helpers.md | 2 +- packages/aio-commerce-lib-app/docs/usage.md | 14 ++-- specs/features/CEXT-6160-association-data.md | 84 +++++++++++--------- 3 files changed, 57 insertions(+), 43 deletions(-) diff --git a/.changeset/commerce-instance-helpers.md b/.changeset/commerce-instance-helpers.md index 5097823a0..f43f81a4e 100644 --- a/.changeset/commerce-instance-helpers.md +++ b/.changeset/commerce-instance-helpers.md @@ -2,4 +2,4 @@ "@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 instance data, and `getCommerceClient` returns a ready-to-use Commerce HTTP client. +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. Pass auth resolved with `resolveAuthParams` from `@adobe/aio-commerce-lib-auth`; the base URL and deployment type come from the stored association. diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index 0fb4ab554..6b6cf1c90 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -788,8 +788,8 @@ After an app is associated with a Commerce instance via App Management, the SDK Two helpers are exposed from the root entrypoint: -- `getCommerceClient(params)` — returns a ready-to-use [`AdobeCommerceHttpClient`](../../aio-commerce-lib-api/docs/usage.md). Use this when you need to call the Commerce API. -- `getCommerceInstance(params)` — returns the raw `{ baseUrl, env }`. Use this when you only need the metadata (e.g. for logging or building a custom client). +- `getCommerceClient(auth)` — 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 auth credentials (resolve them with `resolveAuthParams` from [`@adobe/aio-commerce-lib-auth`](../../aio-commerce-lib-auth/docs/usage.md)). +- `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. @@ -797,9 +797,10 @@ Both helpers throw `AppNotAssociatedError` if the app is not currently associate ```ts import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; +import { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; export async function main(params) { - const client = await getCommerceClient(params); + const client = await getCommerceClient(resolveAuthParams(params)); const products = await client.get("rest/V1/products").json(); } ``` @@ -809,8 +810,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 — Commerce API base URL // instance.env — "saas" | "paas" @@ -827,10 +828,11 @@ import { AppNotAssociatedError, getCommerceClient, } from "@adobe/aio-commerce-lib-app"; +import { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; export async function main(params) { try { - const client = await getCommerceClient(params); + const client = await getCommerceClient(resolveAuthParams(params)); return ok({ body: await client.get("rest/V1/products").json() }); } catch (error) { if (error instanceof AppNotAssociatedError) { diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index 1c75867a5..9198ad09f 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -48,9 +48,10 @@ setup or client construction boilerplate. ```ts import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; +import { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; export async function main(params) { - const client = await getCommerceClient(params); + const client = await getCommerceClient(resolveAuthParams(params)); const products = await client.get("rest/V1/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" @@ -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 ``` @@ -201,16 +203,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 +223,32 @@ 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 Commerce auth credentials. * @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: CommerceHttpClientParams["auth"], ): 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. Auth is resolved outside +the helper with `resolveAuthParams` from `@adobe/aio-commerce-lib-auth`, keeping +`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 { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; + +const client = await getCommerceClient(resolveAuthParams(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 +297,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, From 04b65590087b0d2f4792ffdbfbb664f83ed6ae43 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 00:56:12 -0500 Subject: [PATCH 30/46] CEXT-6160: keep AppNotAssociatedError options as a named seam --- .../source/errors/app-not-associated-error.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 index af89bb495..536468601 100644 --- 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 @@ -17,6 +17,13 @@ import type { CommerceSdkErrorBaseOptions } from "@adobe/aio-commerce-lib-core/e const DEFAULT_MESSAGE = "App is not associated with a Commerce instance. Re-associate the app to resolve this error."; +/** + * Options accepted by {@link AppNotAssociatedError}. Aliases + * {@link 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 @@ -28,9 +35,10 @@ const DEFAULT_MESSAGE = * @example * ```ts * import { getCommerceClient, AppNotAssociatedError } from "@adobe/aio-commerce-lib-app"; + * import { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; * * try { - * const client = await getCommerceClient(params); + * const client = await getCommerceClient(resolveAuthParams(params)); * // ... use client * } catch (error) { * if (error instanceof AppNotAssociatedError) { @@ -43,7 +51,7 @@ const DEFAULT_MESSAGE = export class AppNotAssociatedError extends CommerceSdkErrorBase { public constructor( message: string = DEFAULT_MESSAGE, - options?: CommerceSdkErrorBaseOptions, + options?: AppNotAssociatedErrorOptions, ) { super(message, options); } From de6e29f9886c5533324904b02c9b11d15aa7cc33 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 00:57:42 -0500 Subject: [PATCH 31/46] CEXT-6160: reuse configuration-repository for system config storage --- .changeset/system-config-storage.md | 2 +- .../association/association-repository.ts | 2 +- .../association-repository.test.ts | 2 +- packages/aio-commerce-lib-config/package.json | 11 - .../aio-commerce-lib-config/source/index.ts | 4 + .../configuration/configuration-repository.ts | 198 +++++++++++++---- .../modules/configuration/system-config.ts | 68 ++++++ .../modules/configuration/system/index.ts | 17 -- .../configuration/system/system-repository.ts | 200 ------------------ ...pository.test.ts => system-config.test.ts} | 133 +++++------- .../aio-commerce-lib-config/tsdown.config.ts | 6 +- 11 files changed, 290 insertions(+), 353 deletions(-) create mode 100644 packages/aio-commerce-lib-config/source/modules/configuration/system-config.ts delete mode 100644 packages/aio-commerce-lib-config/source/modules/configuration/system/index.ts delete mode 100644 packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts rename packages/aio-commerce-lib-config/test/unit/modules/configuration/{system/system-repository.test.ts => system-config.test.ts} (58%) diff --git a/.changeset/system-config-storage.md b/.changeset/system-config-storage.md index 795e44482..8346ffc45 100644 --- a/.changeset/system-config-storage.md +++ b/.changeset/system-config-storage.md @@ -2,4 +2,4 @@ "@adobe/aio-commerce-lib-config": minor --- -Add ability to store and retrieve generic SDK system configuration via the new `/system` subpath export. +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/source/modules/association/association-repository.ts b/packages/aio-commerce-lib-app/source/modules/association/association-repository.ts index 71ed175c1..8c4eab379 100644 --- a/packages/aio-commerce-lib-app/source/modules/association/association-repository.ts +++ b/packages/aio-commerce-lib-app/source/modules/association/association-repository.ts @@ -13,7 +13,7 @@ import { getSystemConfigByKey, setSystemConfigByKey, -} from "@adobe/aio-commerce-lib-config/system"; +} from "@adobe/aio-commerce-lib-config"; import type { AssociatedCommerceInstance } from "./types"; diff --git a/packages/aio-commerce-lib-app/test/unit/modules/association/association-repository.test.ts b/packages/aio-commerce-lib-app/test/unit/modules/association/association-repository.test.ts index 4ec2d46f5..5bae463ad 100644 --- a/packages/aio-commerce-lib-app/test/unit/modules/association/association-repository.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/modules/association/association-repository.test.ts @@ -19,7 +19,7 @@ const { mockGetSystemConfigByKey, mockSetSystemConfigByKey } = vi.hoisted( }), ); -vi.mock("@adobe/aio-commerce-lib-config/system", () => ({ +vi.mock("@adobe/aio-commerce-lib-config", () => ({ getSystemConfigByKey: mockGetSystemConfigByKey, setSystemConfigByKey: mockSetSystemConfigByKey, })); diff --git a/packages/aio-commerce-lib-config/package.json b/packages/aio-commerce-lib-config/package.json index b0713ccc9..d4b9a4e71 100644 --- a/packages/aio-commerce-lib-config/package.json +++ b/packages/aio-commerce-lib-config/package.json @@ -48,22 +48,11 @@ "types": "./dist/cjs/index.d.cts", "default": "./dist/cjs/index.cjs" } - }, - "./system": { - "import": { - "types": "./dist/es/modules/configuration/system/index.d.mts", - "default": "./dist/es/modules/configuration/system/index.mjs" - }, - "require": { - "types": "./dist/cjs/modules/configuration/system/index.d.cts", - "default": "./dist/cjs/modules/configuration/system/index.cjs" - } } } }, "exports": { ".": "./source/index.ts", - "./system": "./source/modules/configuration/system/index.ts", "./package.json": "./package.json" }, "imports": { 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..967a55763 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,55 @@ 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; + /** Prefix used to list the namespace's files. */ + filesPrefix: string; +}; + +/** Storage layout for scope-keyed Business Configuration. */ +const CONFIGURATION_NAMESPACE: RepositoryNamespace = { + stateKey: (scopeCode) => `configuration.${scopeCode}`, + filePath: (scopeCode) => + `scope/${scopeCode.toLowerCase()}/configuration.json`, + filesPrefix: "scope/", +}; + +/** + * 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.*`. + */ +export const SYSTEM_NAMESPACE: RepositoryNamespace = { + stateKey: (key) => key, + filePath: (key) => `system/${key}.json`, + filesPrefix: "system/", +}; + /** * 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,27 +82,56 @@ 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,13 +139,17 @@ 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 filePath = namespace.filePath(scopeCode); + const filesList = await files.list(namespace.filesPrefix); const fileObject = filesList.find((file) => file.name === filePath); if (!fileObject) { @@ -96,24 +168,56 @@ 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. Failures are swallowed + * (logged at debug) so clearing a non-existent entry is a no-op. + * + * @param scopeCode - Scope code identifier. + * @param namespace - Storage namespace to delete from. + */ +async function deletePersistedConfig( + scopeCode: string, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, +) { + const logger = getLogger( + "@adobe/aio-commerce-lib-config:configuration-repository", + ); + try { + const files = await getSharedFiles(); + await files.delete(namespace.filePath(scopeCode)); + } catch (error) { + logger.debug( + "Failed to delete persisted configuration:", + error instanceof Error ? error.message : String(error), + ); + } +} + /** * 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 +225,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 +238,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 +285,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 +333,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 +358,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..f0e3f6645 --- /dev/null +++ b/packages/aio-commerce-lib-config/source/modules/configuration/system-config.ts @@ -0,0 +1,68 @@ +/* + * 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, + SYSTEM_NAMESPACE, +} 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; + +/** + * 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/source/modules/configuration/system/index.ts b/packages/aio-commerce-lib-config/source/modules/configuration/system/index.ts deleted file mode 100644 index 9ce595500..000000000 --- a/packages/aio-commerce-lib-config/source/modules/configuration/system/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { - getSystemConfigByKey, - setSystemConfigByKey, -} from "./system-repository"; diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts b/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts deleted file mode 100644 index cfd5dd66b..000000000 --- a/packages/aio-commerce-lib-config/source/modules/configuration/system/system-repository.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* - * 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 stringify from "safe-stable-stringify"; - -import { getLogger } from "#utils/logger"; -import { getSharedFiles, getSharedState } from "#utils/repository"; - -const SYSTEM_FILES_PREFIX = "system/"; - -/** Maps a system config key to its path in `aio-lib-files`. */ -function getSystemFilePath(key: string): string { - return `${SYSTEM_FILES_PREFIX}${key}.json`; -} - -/** - * Parses a stored JSON payload, returning `undefined` instead of throwing when - * the payload is corrupt. A malformed entry must never crash a read: callers - * treat `undefined` as unusable and fall back to the source of truth. No JSON - * text parses to `undefined`, so it is an unambiguous failure sentinel. - */ -function safeJsonParse(raw: string): T | undefined { - try { - return JSON.parse(raw) as T; - } catch (error) { - const logger = getLogger( - "@adobe/aio-commerce-lib-config:system-repository", - ); - logger.debug( - "Failed to parse stored system config payload:", - error instanceof Error ? error.message : String(error), - ); - return; - } -} - -/** - * Reads a raw payload from the `aio-lib-state` cache. Returns `null` on a cache - * miss or any read failure, so callers can transparently fall back to files. - */ -async function readFromCache(key: string): Promise { - try { - const state = await getSharedState(); - const result = await state.get(key); - return result?.value ?? null; - } catch { - return null; - } -} - -/** - * Writes a payload to the `aio-lib-state` cache. Cache failures are swallowed - * (logged at debug) since `aio-lib-files` remains the source of truth. - * - * On a write failure the prior entry is invalidated rather than left in place: - * the cache-first read path trusts a cache hit unconditionally, so a stale - * value must never be allowed to shadow the freshly persisted source of truth. - */ -async function writeToCache(key: string, payload: string): Promise { - const logger = getLogger("@adobe/aio-commerce-lib-config:system-repository"); - try { - const state = await getSharedState(); - await state.put(key, payload); - } catch (error) { - logger.debug( - "Failed to cache system config; invalidating cache entry:", - error instanceof Error ? error.message : String(error), - ); - await deleteFromCache(key); - } -} - -/** - * Removes a key from the `aio-lib-state` cache. Failures are swallowed (logged - * at debug) since the entry is also cleared from `aio-lib-files`. - */ -async function deleteFromCache(key: string): Promise { - const logger = getLogger("@adobe/aio-commerce-lib-config:system-repository"); - try { - const state = await getSharedState(); - await state.delete(key); - } catch (error) { - logger.debug( - "Failed to clear system config cache:", - error instanceof Error ? error.message : String(error), - ); - } -} - -/** - * Reads a raw payload from `aio-lib-files`, the persistent source of truth. - * Returns `null` when the file does not exist or cannot be read. - */ -async function readFromFiles(key: string): Promise { - try { - const files = await getSharedFiles(); - const filePath = getSystemFilePath(key); - const content = await files.read(filePath); - return content ? content.toString("utf8") : null; - } catch { - return null; - } -} - -/** - * Writes a payload to `aio-lib-files`, the persistent source of truth. Failures - * propagate so the caller can surface a failed write. - */ -async function writeToFiles(key: string, payload: string): Promise { - const files = await getSharedFiles(); - const filePath = getSystemFilePath(key); - await files.write(filePath, payload); -} - -/** - * Removes a key's file from `aio-lib-files`. Failures are swallowed (logged at - * debug) so clearing a non-existent entry is a no-op. - */ -async function deleteFromFiles(key: string): Promise { - const logger = getLogger("@adobe/aio-commerce-lib-config:system-repository"); - try { - const files = await getSharedFiles(); - const filePath = getSystemFilePath(key); - await files.delete(filePath); - } catch (error) { - logger.debug( - "Failed to delete system config file:", - error instanceof Error ? error.message : String(error), - ); - } -} - -/** - * Stores or clears a system configuration value by key. - * - * Persists the value to `aio-lib-files` (source of truth) and writes it to - * `aio-lib-state` as a performance cache. Passing `null` or `undefined` clears - * the entry from both storage layers. - * - * @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 deleteFromFiles(key); - await deleteFromCache(key); - return; - } - - const payload = stringify(value) as string; - await writeToFiles(key, payload); - await writeToCache(key, payload); -} - -/** - * Retrieves a system configuration value by key. - * - * Reads from the `aio-lib-state` cache first; on cache miss, falls back to - * `aio-lib-files` (the persistent source of truth) and re-caches the value for - * subsequent reads. 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 { - const cached = await readFromCache(key); - if (cached !== null) { - const parsed = safeJsonParse(cached); - if (parsed !== undefined) { - return parsed; - } - // Corrupt cache entry: fall through to the source of truth. - } - - const persisted = await readFromFiles(key); - if (persisted === null) { - return null; - } - - const parsed = safeJsonParse(persisted); - if (parsed === undefined) { - return null; - } - - // Re-cache for subsequent reads - await writeToCache(key, persisted); - return parsed; -} diff --git a/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts b/packages/aio-commerce-lib-config/test/unit/modules/configuration/system-config.test.ts similarity index 58% rename from packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts rename to packages/aio-commerce-lib-config/test/unit/modules/configuration/system-config.test.ts index dad2dac5a..5f1ca3eda 100644 --- a/packages/aio-commerce-lib-config/test/unit/modules/configuration/system/system-repository.test.ts +++ b/packages/aio-commerce-lib-config/test/unit/modules/configuration/system-config.test.ts @@ -31,41 +31,59 @@ vi.mock("#utils/repository", () => ({ getSharedFiles: vi.fn().mockResolvedValue(mockFiles), })); -describe("system-repository", () => { +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(); }); describe("setSystemConfigByKey", () => { - test("writes the serialized value to both files and state", async () => { + test("writes the serialized value to files and caches it in state", async () => { const { setSystemConfigByKey } = await import( - "#modules/configuration/system/system-repository" + "#modules/configuration/system-config" ); const data = { baseUrl: "https://example.com", env: "paas" }; - await setSystemConfigByKey("system.association", data); + await setSystemConfigByKey(KEY, data); expect(mockFiles.write).toHaveBeenCalledWith( - "system/system.association.json", - JSON.stringify(data), - ); - expect(mockState.put).toHaveBeenCalledWith( - "system.association", + 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/system-repository" + "#modules/configuration/system-config" ); - await setSystemConfigByKey("system.association", null); + 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(); + }); - expect(mockFiles.delete).toHaveBeenCalledWith( - "system/system.association.json", + test("clears both files and state when value is undefined", async () => { + const { setSystemConfigByKey } = await import( + "#modules/configuration/system-config" ); - expect(mockState.delete).toHaveBeenCalledWith("system.association"); + + 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(); }); @@ -73,81 +91,64 @@ describe("system-repository", () => { 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/system-repository" + "#modules/configuration/system-config" ); await expect( - setSystemConfigByKey("system.association", { foo: "bar" }), + 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("system.association"); - }); - - test("clears both files and state when value is undefined", async () => { - const { setSystemConfigByKey } = await import( - "#modules/configuration/system/system-repository" - ); - - await setSystemConfigByKey("system.association", undefined); - - expect(mockFiles.delete).toHaveBeenCalledWith( - "system/system.association.json", - ); - expect(mockState.delete).toHaveBeenCalledWith("system.association"); - expect(mockFiles.write).not.toHaveBeenCalled(); - expect(mockState.put).not.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" }; - mockState.get.mockResolvedValue({ value: JSON.stringify(data) }); + mockState.get.mockResolvedValue({ value: wrapForCache(data) }); const { getSystemConfigByKey } = await import( - "#modules/configuration/system/system-repository" + "#modules/configuration/system-config" ); - const result = await getSystemConfigByKey("system.association"); + const result = await getSystemConfigByKey(KEY); expect(result).toEqual(data); expect(mockFiles.list).not.toHaveBeenCalled(); expect(mockFiles.read).not.toHaveBeenCalled(); }); - test("falls back to files when cache miss, then re-caches", async () => { + test("falls back to files when cache misses, then re-caches", async () => { const data = { baseUrl: "https://example.com", env: "paas" }; mockState.get.mockResolvedValue({ value: null }); + mockFiles.list.mockResolvedValue([{ name: FILE_PATH }]); mockFiles.read.mockResolvedValue(Buffer.from(JSON.stringify(data))); const { getSystemConfigByKey } = await import( - "#modules/configuration/system/system-repository" + "#modules/configuration/system-config" ); - const result = await getSystemConfigByKey("system.association"); + const result = await getSystemConfigByKey(KEY); expect(result).toEqual(data); - expect(mockFiles.read).toHaveBeenCalledWith( - "system/system.association.json", - ); - expect(mockState.put).toHaveBeenCalledWith( - "system.association", - JSON.stringify(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 () => { mockState.get.mockResolvedValue({ value: null }); - mockFiles.read.mockRejectedValue(new Error("file not found")); + mockFiles.list.mockResolvedValue([]); const { getSystemConfigByKey } = await import( - "#modules/configuration/system/system-repository" + "#modules/configuration/system-config" ); - const result = await getSystemConfigByKey("system.association"); + const result = await getSystemConfigByKey(KEY); expect(result).toBeNull(); expect(mockState.put).not.toHaveBeenCalled(); @@ -158,23 +159,10 @@ describe("system-repository", () => { mockFiles.list.mockResolvedValue([]); const { getSystemConfigByKey } = await import( - "#modules/configuration/system/system-repository" + "#modules/configuration/system-config" ); - const result = await getSystemConfigByKey("system.association"); - - expect(result).toBeNull(); - }); - - test("returns null when files read throws", async () => { - mockState.get.mockResolvedValue({ value: null }); - mockFiles.read.mockRejectedValue(new Error("files error")); - - const { getSystemConfigByKey } = await import( - "#modules/configuration/system/system-repository" - ); - - const result = await getSystemConfigByKey("system.association"); + const result = await getSystemConfigByKey(KEY); expect(result).toBeNull(); }); @@ -182,36 +170,31 @@ describe("system-repository", () => { test("falls back to files when the cached value is corrupt JSON", async () => { const data = { baseUrl: "https://example.com", env: "paas" }; mockState.get.mockResolvedValue({ value: "{not valid json" }); + mockFiles.list.mockResolvedValue([{ name: FILE_PATH }]); mockFiles.read.mockResolvedValue(Buffer.from(JSON.stringify(data))); const { getSystemConfigByKey } = await import( - "#modules/configuration/system/system-repository" + "#modules/configuration/system-config" ); - const result = await getSystemConfigByKey("system.association"); + const result = await getSystemConfigByKey(KEY); expect(result).toEqual(data); - expect(mockFiles.read).toHaveBeenCalledWith( - "system/system.association.json", - ); - expect(mockState.put).toHaveBeenCalledWith( - "system.association", - JSON.stringify(data), - ); + expect(mockFiles.read).toHaveBeenCalledWith(FILE_PATH); }); test("returns null when the persisted file holds corrupt JSON", async () => { mockState.get.mockResolvedValue({ value: null }); + mockFiles.list.mockResolvedValue([{ name: FILE_PATH }]); mockFiles.read.mockResolvedValue(Buffer.from("{not valid json")); const { getSystemConfigByKey } = await import( - "#modules/configuration/system/system-repository" + "#modules/configuration/system-config" ); - const result = await getSystemConfigByKey("system.association"); + const result = await getSystemConfigByKey(KEY); expect(result).toBeNull(); - expect(mockState.put).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/aio-commerce-lib-config/tsdown.config.ts b/packages/aio-commerce-lib-config/tsdown.config.ts index 6324c46ff..80c3b9887 100644 --- a/packages/aio-commerce-lib-config/tsdown.config.ts +++ b/packages/aio-commerce-lib-config/tsdown.config.ts @@ -14,9 +14,5 @@ import { baseConfig } from "@aio-commerce-sdk/config-tsdown/tsdown.config.base"; import { mergeConfig } from "tsdown"; export default mergeConfig(baseConfig, { - entry: [ - "./source/index.ts", - "./source/commands/index.ts", - "./source/modules/configuration/system/index.ts", - ], + entry: ["./source/index.ts", "./source/commands/index.ts"], }); From b2903198d72391d1fa8375c867ce65d5c973b6e8 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 09:41:56 -0500 Subject: [PATCH 32/46] CEXT-6160: restore caching-failure comment in configuration-repository --- .../source/modules/configuration/configuration-repository.ts | 2 ++ 1 file changed, 2 insertions(+) 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 967a55763..51b1c1d5b 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 @@ -132,6 +132,7 @@ async function deleteCachedConfig( "Failed to clear cached configuration:", error instanceof Error ? error.message : String(error), ); + // Don't throw - caching failure shouldn't break functionality } } @@ -202,6 +203,7 @@ async function deletePersistedConfig( "Failed to delete persisted configuration:", error instanceof Error ? error.message : String(error), ); + // Don't throw - caching failure shouldn't break functionality } } From 5e948fe6bb6dca7d20aad06228c8c78d9b2ef854 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 10:08:24 -0500 Subject: [PATCH 33/46] CEXT-6160: read config file directly instead of list-then-read --- .../configuration/configuration-repository.ts | 14 +------------- .../test/mocks/lib-files.ts | 13 ++++++++++--- .../modules/configuration/system-config.test.ts | 9 ++------- 3 files changed, 13 insertions(+), 23 deletions(-) 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 51b1c1d5b..61ff6ae35 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 @@ -27,8 +27,6 @@ export type RepositoryNamespace = { stateKey: (id: string) => string; /** Builds the `aio-lib-files` path for an id. */ filePath: (id: string) => string; - /** Prefix used to list the namespace's files. */ - filesPrefix: string; }; /** Storage layout for scope-keyed Business Configuration. */ @@ -36,7 +34,6 @@ const CONFIGURATION_NAMESPACE: RepositoryNamespace = { stateKey: (scopeCode) => `configuration.${scopeCode}`, filePath: (scopeCode) => `scope/${scopeCode.toLowerCase()}/configuration.json`, - filesPrefix: "scope/", }; /** @@ -47,7 +44,6 @@ const CONFIGURATION_NAMESPACE: RepositoryNamespace = { export const SYSTEM_NAMESPACE: RepositoryNamespace = { stateKey: (key) => key, filePath: (key) => `system/${key}.json`, - filesPrefix: "system/", }; /** @@ -149,15 +145,7 @@ export async function getPersistedConfig( ) { try { const files = await getSharedFiles(); - const filePath = namespace.filePath(scopeCode); - const filesList = await files.list(namespace.filesPrefix); - 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; 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..d2d10bef4 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 ( 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 index 5f1ca3eda..4d503b497 100644 --- 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 @@ -19,7 +19,6 @@ const { mockState, mockFiles } = vi.hoisted(() => ({ delete: vi.fn(), }, mockFiles: { - list: vi.fn(), read: vi.fn(), write: vi.fn(), delete: vi.fn(), @@ -117,14 +116,12 @@ describe("system-config", () => { const result = await getSystemConfigByKey(KEY); expect(result).toEqual(data); - expect(mockFiles.list).not.toHaveBeenCalled(); 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" }; mockState.get.mockResolvedValue({ value: null }); - mockFiles.list.mockResolvedValue([{ name: FILE_PATH }]); mockFiles.read.mockResolvedValue(Buffer.from(JSON.stringify(data))); const { getSystemConfigByKey } = await import( @@ -142,7 +139,7 @@ describe("system-config", () => { test("returns null when not found in cache or files", async () => { mockState.get.mockResolvedValue({ value: null }); - mockFiles.list.mockResolvedValue([]); + mockFiles.read.mockRejectedValue({ code: "ENOENT" }); const { getSystemConfigByKey } = await import( "#modules/configuration/system-config" @@ -156,7 +153,7 @@ describe("system-config", () => { test("returns null when state read throws", async () => { mockState.get.mockRejectedValue(new Error("state error")); - mockFiles.list.mockResolvedValue([]); + mockFiles.read.mockRejectedValue({ code: "ENOENT" }); const { getSystemConfigByKey } = await import( "#modules/configuration/system-config" @@ -170,7 +167,6 @@ describe("system-config", () => { test("falls back to files when the cached value is corrupt JSON", async () => { const data = { baseUrl: "https://example.com", env: "paas" }; mockState.get.mockResolvedValue({ value: "{not valid json" }); - mockFiles.list.mockResolvedValue([{ name: FILE_PATH }]); mockFiles.read.mockResolvedValue(Buffer.from(JSON.stringify(data))); const { getSystemConfigByKey } = await import( @@ -185,7 +181,6 @@ describe("system-config", () => { test("returns null when the persisted file holds corrupt JSON", async () => { mockState.get.mockResolvedValue({ value: null }); - mockFiles.list.mockResolvedValue([{ name: FILE_PATH }]); mockFiles.read.mockResolvedValue(Buffer.from("{not valid json")); const { getSystemConfigByKey } = await import( From 3f0933371aded031a7aa616c9088a5a04aa6d223 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 10:15:46 -0500 Subject: [PATCH 34/46] CEXT-6160: bubble up persisted config delete errors --- .../configuration/configuration-repository.ts | 20 +++++-------------- .../configuration/system-config.test.ts | 11 ++++++++++ 2 files changed, 16 insertions(+), 15 deletions(-) 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 61ff6ae35..1a1653087 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 @@ -170,8 +170,9 @@ export async function saveConfig( } /** - * Removes a configuration entry's file from storage. Failures are swallowed - * (logged at debug) so clearing a non-existent entry is a no-op. + * 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. @@ -180,19 +181,8 @@ async function deletePersistedConfig( scopeCode: string, namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, ) { - const logger = getLogger( - "@adobe/aio-commerce-lib-config:configuration-repository", - ); - try { - const files = await getSharedFiles(); - await files.delete(namespace.filePath(scopeCode)); - } catch (error) { - logger.debug( - "Failed to delete persisted configuration:", - error instanceof Error ? error.message : String(error), - ); - // Don't throw - caching failure shouldn't break functionality - } + const files = await getSharedFiles(); + await files.delete(namespace.filePath(scopeCode)); } /** 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 index 4d503b497..4153f09ea 100644 --- 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 @@ -87,6 +87,17 @@ describe("system-config", () => { 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( From f1130a74efadfd17c636f53ac72ec2b75fcf0858 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 23:43:36 -0500 Subject: [PATCH 35/46] CEXT-6160: reuse shared storage mocks in system-config tests --- .../association/association-repository.ts | 0 .../association/index.ts | 0 .../association/types.ts | 0 .../association-repository.test.ts | 0 .../test/mocks/lib-files.ts | 6 +++ .../configuration/system-config.test.ts | 44 ++++++++----------- 6 files changed, 24 insertions(+), 26 deletions(-) rename packages/aio-commerce-lib-app/source/{modules => management}/association/association-repository.ts (100%) rename packages/aio-commerce-lib-app/source/{modules => management}/association/index.ts (100%) rename packages/aio-commerce-lib-app/source/{modules => management}/association/types.ts (100%) rename packages/aio-commerce-lib-app/test/unit/{modules => management}/association/association-repository.test.ts (100%) diff --git a/packages/aio-commerce-lib-app/source/modules/association/association-repository.ts b/packages/aio-commerce-lib-app/source/management/association/association-repository.ts similarity index 100% rename from packages/aio-commerce-lib-app/source/modules/association/association-repository.ts rename to packages/aio-commerce-lib-app/source/management/association/association-repository.ts diff --git a/packages/aio-commerce-lib-app/source/modules/association/index.ts b/packages/aio-commerce-lib-app/source/management/association/index.ts similarity index 100% rename from packages/aio-commerce-lib-app/source/modules/association/index.ts rename to packages/aio-commerce-lib-app/source/management/association/index.ts diff --git a/packages/aio-commerce-lib-app/source/modules/association/types.ts b/packages/aio-commerce-lib-app/source/management/association/types.ts similarity index 100% rename from packages/aio-commerce-lib-app/source/modules/association/types.ts rename to packages/aio-commerce-lib-app/source/management/association/types.ts diff --git a/packages/aio-commerce-lib-app/test/unit/modules/association/association-repository.test.ts b/packages/aio-commerce-lib-app/test/unit/management/association/association-repository.test.ts similarity index 100% rename from packages/aio-commerce-lib-app/test/unit/modules/association/association-repository.test.ts rename to packages/aio-commerce-lib-app/test/unit/management/association/association-repository.test.ts 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 d2d10bef4..1f82f4cb9 100644 --- a/packages/aio-commerce-lib-config/test/mocks/lib-files.ts +++ b/packages/aio-commerce-lib-config/test/mocks/lib-files.ts @@ -55,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 index 4153f09ea..bc4badba0 100644 --- 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 @@ -12,22 +12,18 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -const { mockState, mockFiles } = vi.hoisted(() => ({ - mockState: { - get: vi.fn(), - put: vi.fn(), - delete: vi.fn(), - }, - mockFiles: { - read: vi.fn(), - write: vi.fn(), - delete: vi.fn(), - }, -})); +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().mockResolvedValue(mockState), - getSharedFiles: vi.fn().mockResolvedValue(mockFiles), + getSharedState: vi.fn(async () => mockState), + getSharedFiles: vi.fn(async () => mockFiles), })); const KEY = "system.association"; @@ -41,6 +37,8 @@ function wrapForCache(value: unknown): string { describe("system-config", () => { beforeEach(() => { vi.clearAllMocks(); + mockState = new MockState(); + mockFiles = new MockFiles(); }); describe("setSystemConfigByKey", () => { @@ -118,7 +116,7 @@ describe("system-config", () => { describe("getSystemConfigByKey", () => { test("returns the cached value when present in state", async () => { const data = { baseUrl: "https://example.com", env: "saas" }; - mockState.get.mockResolvedValue({ value: wrapForCache(data) }); + await mockState.put(KEY, wrapForCache(data)); const { getSystemConfigByKey } = await import( "#modules/configuration/system-config" @@ -132,8 +130,7 @@ describe("system-config", () => { test("falls back to files when cache misses, then re-caches", async () => { const data = { baseUrl: "https://example.com", env: "paas" }; - mockState.get.mockResolvedValue({ value: null }); - mockFiles.read.mockResolvedValue(Buffer.from(JSON.stringify(data))); + await mockFiles.write(FILE_PATH, JSON.stringify(data)); const { getSystemConfigByKey } = await import( "#modules/configuration/system-config" @@ -149,9 +146,6 @@ describe("system-config", () => { }); test("returns null when not found in cache or files", async () => { - mockState.get.mockResolvedValue({ value: null }); - mockFiles.read.mockRejectedValue({ code: "ENOENT" }); - const { getSystemConfigByKey } = await import( "#modules/configuration/system-config" ); @@ -163,8 +157,7 @@ describe("system-config", () => { }); test("returns null when state read throws", async () => { - mockState.get.mockRejectedValue(new Error("state error")); - mockFiles.read.mockRejectedValue({ code: "ENOENT" }); + mockState.get.mockRejectedValueOnce(new Error("state error")); const { getSystemConfigByKey } = await import( "#modules/configuration/system-config" @@ -177,8 +170,8 @@ describe("system-config", () => { test("falls back to files when the cached value is corrupt JSON", async () => { const data = { baseUrl: "https://example.com", env: "paas" }; - mockState.get.mockResolvedValue({ value: "{not valid json" }); - mockFiles.read.mockResolvedValue(Buffer.from(JSON.stringify(data))); + mockState.get.mockResolvedValueOnce({ value: "{not valid json" }); + await mockFiles.write(FILE_PATH, JSON.stringify(data)); const { getSystemConfigByKey } = await import( "#modules/configuration/system-config" @@ -191,8 +184,7 @@ describe("system-config", () => { }); test("returns null when the persisted file holds corrupt JSON", async () => { - mockState.get.mockResolvedValue({ value: null }); - mockFiles.read.mockResolvedValue(Buffer.from("{not valid json")); + await mockFiles.write(FILE_PATH, "{not valid json"); const { getSystemConfigByKey } = await import( "#modules/configuration/system-config" From 9b42d0ecc9b580e67facf5560babec112f65afa8 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 23:44:26 -0500 Subject: [PATCH 36/46] CEXT-6160: move SYSTEM_NAMESPACE into system-config and default getSystemConfigByKey --- .../configuration/configuration-repository.ts | 10 ---------- .../modules/configuration/system-config.ts | 17 +++++++++++++++-- 2 files changed, 15 insertions(+), 12 deletions(-) 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 1a1653087..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 @@ -36,16 +36,6 @@ const CONFIGURATION_NAMESPACE: RepositoryNamespace = { `scope/${scopeCode.toLowerCase()}/configuration.json`, }; -/** - * 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.*`. - */ -export const SYSTEM_NAMESPACE: RepositoryNamespace = { - stateKey: (key) => key, - filePath: (key) => `system/${key}.json`, -}; - /** * Gets cached configuration payload from state store. * 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 index f0e3f6645..8770a9aab 100644 --- a/packages/aio-commerce-lib-config/source/modules/configuration/system-config.ts +++ b/packages/aio-commerce-lib-config/source/modules/configuration/system-config.ts @@ -14,14 +14,25 @@ import { deleteConfig, loadConfig, persistConfig, - SYSTEM_NAMESPACE, } 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. * @@ -59,7 +70,9 @@ export async function setSystemConfigByKey( * @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 { +export async function getSystemConfigByKey( + key: string, +): Promise { return (await loadConfig( key, SYSTEM_CONFIG_CACHE_TTL_SECONDS, From b83a64f672cce4090c5345a22f03be171f516e1a Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 23:45:10 -0500 Subject: [PATCH 37/46] CEXT-6160: add changeset for CommerceSdkErrorBaseOptions export --- .changeset/error-base-options-export.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/error-base-options-export.md 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`. From 36fa0c9d280e238011b82458e0f8249928dedf39 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 23:46:03 -0500 Subject: [PATCH 38/46] CEXT-6160: removed implementation detail from changeset --- .changeset/commerce-instance-helpers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/commerce-instance-helpers.md b/.changeset/commerce-instance-helpers.md index f43f81a4e..1f50c87ae 100644 --- a/.changeset/commerce-instance-helpers.md +++ b/.changeset/commerce-instance-helpers.md @@ -2,4 +2,4 @@ "@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. Pass auth resolved with `resolveAuthParams` from `@adobe/aio-commerce-lib-auth`; the base URL and deployment type come from the stored association. +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. From 6d4c3c3562f4d36592d589ccd70a92748bd70230 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 23:47:15 -0500 Subject: [PATCH 39/46] CEXT-6160: split internal rationale out of AppNotAssociatedErrorOptions doc --- .../source/errors/app-not-associated-error.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 index 536468601..cd8cd04df 100644 --- 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 @@ -17,11 +17,10 @@ import type { CommerceSdkErrorBaseOptions } from "@adobe/aio-commerce-lib-core/e const DEFAULT_MESSAGE = "App is not associated with a Commerce instance. Re-associate the app to resolve this error."; -/** - * Options accepted by {@link AppNotAssociatedError}. Aliases - * {@link CommerceSdkErrorBaseOptions} for now, kept as a named seam so future - * error-specific options can be added without changing the constructor contract. - */ +/** 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; /** @@ -35,10 +34,10 @@ type AppNotAssociatedErrorOptions = CommerceSdkErrorBaseOptions; * @example * ```ts * import { getCommerceClient, AppNotAssociatedError } from "@adobe/aio-commerce-lib-app"; - * import { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; + * import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; * * try { - * const client = await getCommerceClient(resolveAuthParams(params)); + * const client = await getCommerceClient(resolveImsAuthParams(params)); * // ... use client * } catch (error) { * if (error instanceof AppNotAssociatedError) { From 871f7fb66d5fbcbc2bfc976ceb71ec627cb58d17 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 23:48:31 -0500 Subject: [PATCH 40/46] CEXT-6160: move association under management, restrict getCommerceClient to IMS, return 204 from POST /association --- packages/aio-commerce-lib-app/source/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/aio-commerce-lib-app/source/index.ts b/packages/aio-commerce-lib-app/source/index.ts index ec4cd4d5d..5dab8acba 100644 --- a/packages/aio-commerce-lib-app/source/index.ts +++ b/packages/aio-commerce-lib-app/source/index.ts @@ -25,4 +25,4 @@ export { } from "./access/commerce-instance"; export { AppNotAssociatedError } from "./errors/app-not-associated-error"; -export type { AssociatedCommerceInstance } from "./modules/association/types"; +export type { AssociatedCommerceInstance } from "./management/association/types"; From cb74abd5855bdd71d883c1e216279732acd77821 Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 23:55:09 -0500 Subject: [PATCH 41/46] CEXT-6160: harden getCommerceClient (IMS-only, fetch options) and return 204 from POST /association --- .../aio-commerce-lib-app/docs/openapi.json | 21 ++------------ packages/aio-commerce-lib-app/docs/usage.md | 14 ++++----- .../source/access/commerce-instance.ts | 29 ++++++++++++------- .../test/unit/actions/association.test.ts | 12 +++----- .../test/unit/index.test.ts | 19 +++++++++++- .../association-repository.test.ts | 2 +- 6 files changed, 51 insertions(+), 46 deletions(-) diff --git a/packages/aio-commerce-lib-app/docs/openapi.json b/packages/aio-commerce-lib-app/docs/openapi.json index c44c1640c..ac49f788b 100644 --- a/packages/aio-commerce-lib-app/docs/openapi.json +++ b/packages/aio-commerce-lib-app/docs/openapi.json @@ -2709,25 +2709,8 @@ } }, "responses": { - "200": { - "description": "Association data stored successfully.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "baseUrl": { - "type": "string" - }, - "env": { - "type": "string", - "enum": ["saas", "paas"] - } - }, - "required": ["baseUrl", "env"] - } - } - } + "204": { + "description": "Association data stored successfully." }, "400": { "description": "Bad request, the request body is invalid.", diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index 6b6cf1c90..050718b92 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -788,7 +788,7 @@ After an app is associated with a Commerce instance via App Management, the SDK Two helpers are exposed from the root entrypoint: -- `getCommerceClient(auth)` — 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 auth credentials (resolve them with `resolveAuthParams` from [`@adobe/aio-commerce-lib-auth`](../../aio-commerce-lib-auth/docs/usage.md)). +- `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. @@ -797,11 +797,11 @@ Both helpers throw `AppNotAssociatedError` if the app is not currently associate ```ts import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; -import { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; +import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; export async function main(params) { - const client = await getCommerceClient(resolveAuthParams(params)); - const products = await client.get("rest/V1/products").json(); + const client = await getCommerceClient(resolveImsAuthParams(params)); + const products = await client.get("products").json(); } ``` @@ -828,12 +828,12 @@ import { AppNotAssociatedError, getCommerceClient, } from "@adobe/aio-commerce-lib-app"; -import { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; +import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; export async function main(params) { try { - const client = await getCommerceClient(resolveAuthParams(params)); - return ok({ body: await client.get("rest/V1/products").json() }); + const client = await getCommerceClient(resolveImsAuthParams(params)); + return ok({ body: await client.get("products").json() }); } catch (error) { if (error instanceof AppNotAssociatedError) { return badRequest({ diff --git a/packages/aio-commerce-lib-app/source/access/commerce-instance.ts b/packages/aio-commerce-lib-app/source/access/commerce-instance.ts index ebc04e833..785149499 100644 --- a/packages/aio-commerce-lib-app/source/access/commerce-instance.ts +++ b/packages/aio-commerce-lib-app/source/access/commerce-instance.ts @@ -13,10 +13,14 @@ import { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; import { AppNotAssociatedError } from "../errors/app-not-associated-error"; -import { getAssociationData } from "../modules/association/association-repository"; +import { getAssociationData } from "../management/association/association-repository"; import type { CommerceHttpClientParams } from "@adobe/aio-commerce-lib-api"; -import type { AssociatedCommerceInstance } from "../modules/association/types"; +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. @@ -51,11 +55,14 @@ export async function getCommerceInstance(): Promise * * The base URL and flavor come from the stored association data * ({@link getCommerceInstance}); only the auth credentials are supplied by the - * caller, already resolved. Resolve them outside with `resolveAuthParams` from - * `@adobe/aio-commerce-lib-auth` so this helper stays focused on building the - * client for the associated instance. + * 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 Commerce auth credentials. + * @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. @@ -63,16 +70,17 @@ export async function getCommerceInstance(): Promise * @example * ```ts * import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; - * import { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; + * import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; * * export async function main(params) { - * const client = await getCommerceClient(resolveAuthParams(params)); - * const products = await client.get("rest/V1/products").json(); + * const client = await getCommerceClient(resolveImsAuthParams(params)); + * const products = await client.get("products").json(); * } * ``` */ export async function getCommerceClient( - auth: CommerceHttpClientParams["auth"], + auth: ImsAuthParams | ImsAuthProvider, + fetchOptions?: CommerceHttpClientParams["fetchOptions"], ): Promise { const instance = await getCommerceInstance(); @@ -82,5 +90,6 @@ export async function getCommerceClient( return new AdobeCommerceHttpClient({ auth, config: { baseUrl: instance.baseUrl, flavor: instance.env }, + fetchOptions, } as CommerceHttpClientParams); } 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 index 16aa3f23c..5c74fcb3d 100644 --- a/packages/aio-commerce-lib-app/test/unit/actions/association.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/actions/association.test.ts @@ -17,7 +17,7 @@ const { mockSetAssociationData, mockClearAssociationData } = vi.hoisted(() => ({ mockClearAssociationData: vi.fn(), })); -vi.mock("#modules/association/association-repository", () => ({ +vi.mock("#management/association/association-repository", () => ({ setAssociationData: mockSetAssociationData, clearAssociationData: mockClearAssociationData, })); @@ -31,7 +31,7 @@ describe("associationRuntimeAction", () => { }); describe("POST /", () => { - test("stores valid association data and returns 200 with the saved values", async () => { + test("stores valid association data and returns 204", async () => { const action = associationRuntimeAction(); const params = createRuntimeActionParams({ method: "post", @@ -50,11 +50,7 @@ describe("associationRuntimeAction", () => { }); expect(result).toMatchObject({ type: "success", - statusCode: 200, - body: { - baseUrl: "https://example.com", - env: "paas", - }, + statusCode: 204, }); }); @@ -77,7 +73,7 @@ describe("associationRuntimeAction", () => { }); expect(result).toMatchObject({ type: "success", - statusCode: 200, + statusCode: 204, }); }); diff --git a/packages/aio-commerce-lib-app/test/unit/index.test.ts b/packages/aio-commerce-lib-app/test/unit/index.test.ts index d64f1c94c..3c04eca96 100644 --- a/packages/aio-commerce-lib-app/test/unit/index.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/index.test.ts @@ -19,7 +19,7 @@ const { mockGetAssociationData, MockAdobeCommerceHttpClient } = vi.hoisted( }), ); -vi.mock("#modules/association/association-repository", () => ({ +vi.mock("#management/association/association-repository", () => ({ getAssociationData: mockGetAssociationData, })); @@ -109,6 +109,23 @@ describe("getCommerceClient", () => { }); }); + 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); 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 index 5bae463ad..33ccd5911 100644 --- 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 @@ -28,7 +28,7 @@ import { clearAssociationData, getAssociationData, setAssociationData, -} from "#modules/association/association-repository"; +} from "#management/association/association-repository"; describe("association-repository", () => { beforeEach(() => { From 88989848f208dd24f96c74ba82665c0422932e4c Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Tue, 16 Jun 2026 23:59:25 -0500 Subject: [PATCH 42/46] CEXT-6160: address PR review feedback --- .../source/actions/association/router.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/actions/association/router.ts b/packages/aio-commerce-lib-app/source/actions/association/router.ts index 96dd88bd6..67d05bdb1 100644 --- a/packages/aio-commerce-lib-app/source/actions/association/router.ts +++ b/packages/aio-commerce-lib-app/source/actions/association/router.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { noContent, ok } from "@adobe/aio-commerce-lib-core/responses"; +import { noContent } from "@adobe/aio-commerce-lib-core/responses"; import { HttpActionRouter, logger, @@ -19,7 +19,7 @@ import { import { clearAssociationData, setAssociationData, -} from "#modules/association/association-repository"; +} from "#management/association/association-repository"; import { AssociationRequestBodySchema } from "./schema"; @@ -56,9 +56,7 @@ router.post("/", { await setAssociationData({ baseUrl: commerceBaseUrl, env: commerceEnv }); - return ok({ - body: { baseUrl: commerceBaseUrl, env: commerceEnv }, - }); + return noContent(); }, }); From 120de50c7c76ef4f5aefe788cc5dce6dc4cd52ba Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 17 Jun 2026 00:12:36 -0500 Subject: [PATCH 43/46] CEXT-6160: use CommerceEnv from lib-core for association env --- .../aio-commerce-lib-app/source/actions/association/schema.ts | 3 ++- .../source/management/association/types.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/actions/association/schema.ts b/packages/aio-commerce-lib-app/source/actions/association/schema.ts index d2ecb7456..f3bd0fbf2 100644 --- a/packages/aio-commerce-lib-app/source/actions/association/schema.ts +++ b/packages/aio-commerce-lib-app/source/actions/association/schema.ts @@ -10,6 +10,7 @@ * 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. */ @@ -20,5 +21,5 @@ export const AssociationRequestBodySchema = v.object({ "The 'commerceBaseUrl' field must be a valid absolute URL (e.g., 'https://my-store.example.com')", ), ), - commerceEnv: v.picklist(["saas", "paas"]), + commerceEnv: CommerceEnvSchema, }); diff --git a/packages/aio-commerce-lib-app/source/management/association/types.ts b/packages/aio-commerce-lib-app/source/management/association/types.ts index 1b47f7dee..d05d1a985 100644 --- a/packages/aio-commerce-lib-app/source/management/association/types.ts +++ b/packages/aio-commerce-lib-app/source/management/association/types.ts @@ -10,6 +10,8 @@ * 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. */ @@ -17,5 +19,5 @@ export type AssociatedCommerceInstance = { /** Commerce API base URL. */ baseUrl: string; /** Deployment type of the Commerce instance. */ - env: "saas" | "paas"; + env: CommerceEnv; }; From 835cb899f45172a031bb9888c773ed065811734e Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 17 Jun 2026 00:17:08 -0500 Subject: [PATCH 44/46] CEXT-6160: test association repository via round-trip storage --- .../association-repository.test.ts | 93 ++++++++++--------- 1 file changed, 48 insertions(+), 45 deletions(-) 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 index 33ccd5911..d5a104f3d 100644 --- 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 @@ -12,12 +12,26 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -const { mockGetSystemConfigByKey, mockSetSystemConfigByKey } = vi.hoisted( - () => ({ - mockGetSystemConfigByKey: vi.fn(), - mockSetSystemConfigByKey: vi.fn(), - }), -); +// 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, @@ -33,56 +47,45 @@ import { describe("association-repository", () => { beforeEach(() => { vi.clearAllMocks(); + store.clear(); }); - describe("setAssociationData", () => { - test("writes the data under the system.association key", async () => { - const data = { - baseUrl: "https://example.com", - env: "paas" as const, - }; - await setAssociationData(data); + test("stores association data and reads it back unchanged", async () => { + const data = { + baseUrl: "https://example.com", + env: "paas" as const, + }; + await setAssociationData(data); - expect(mockSetSystemConfigByKey).toHaveBeenCalledWith( - "system.association", - data, - ); - }); + expect(await getAssociationData()).toEqual(data); }); - describe("getAssociationData", () => { - test("reads from the system.association key", async () => { - const data = { - baseUrl: "https://example.com", - env: "saas" as const, - }; - mockGetSystemConfigByKey.mockResolvedValue(data); - - const result = await getAssociationData(); + test("returns null when no association data has been stored", async () => { + expect(await getAssociationData()).toBeNull(); + }); - expect(mockGetSystemConfigByKey).toHaveBeenCalledWith( - "system.association", - ); - expect(result).toEqual(data); + test("overwrites previously stored association data", async () => { + await setAssociationData({ + baseUrl: "https://first.example.com", + env: "paas", }); - test("returns null when no data is stored", async () => { - mockGetSystemConfigByKey.mockResolvedValue(null); + const updated = { + baseUrl: "https://second.example.com", + env: "saas" as const, + }; + await setAssociationData(updated); - const result = await getAssociationData(); - - expect(result).toBeNull(); - }); + expect(await getAssociationData()).toEqual(updated); }); - describe("clearAssociationData", () => { - test("clears the system.association key by setting it to null", async () => { - await clearAssociationData(); - - expect(mockSetSystemConfigByKey).toHaveBeenCalledWith( - "system.association", - null, - ); + test("clears stored association data", async () => { + await setAssociationData({ + baseUrl: "https://example.com", + env: "saas", }); + await clearAssociationData(); + + expect(await getAssociationData()).toBeNull(); }); }); From 0cc7ddec0b4a8d9f281a9b0e952073728c3ffd5a Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 17 Jun 2026 00:21:29 -0500 Subject: [PATCH 45/46] CEXT-6160: rename stale index test to access/commerce-instance --- .../unit/{index.test.ts => access/commerce-instance.test.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename packages/aio-commerce-lib-app/test/unit/{index.test.ts => access/commerce-instance.test.ts} (97%) diff --git a/packages/aio-commerce-lib-app/test/unit/index.test.ts b/packages/aio-commerce-lib-app/test/unit/access/commerce-instance.test.ts similarity index 97% rename from packages/aio-commerce-lib-app/test/unit/index.test.ts rename to packages/aio-commerce-lib-app/test/unit/access/commerce-instance.test.ts index 3c04eca96..fce76a293 100644 --- a/packages/aio-commerce-lib-app/test/unit/index.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/access/commerce-instance.test.ts @@ -28,10 +28,10 @@ vi.mock("@adobe/aio-commerce-lib-api", () => ({ })); import { - AppNotAssociatedError, getCommerceClient, getCommerceInstance, -} from "#index"; +} from "#access/commerce-instance"; +import { AppNotAssociatedError } from "#errors/app-not-associated-error"; const auth = { strategy: "ims" } as never; From dd4c61cf4d743af3f2effd9c0599c92c3c65086b Mon Sep 17 00:00:00 2001 From: vinayrao2000 Date: Wed, 17 Jun 2026 00:31:33 -0500 Subject: [PATCH 46/46] CEXT-6160: align spec with final association design --- specs/features/CEXT-6160-association-data.md | 51 +++++++++++--------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index 9198ad09f..433192d0b 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -48,11 +48,11 @@ setup or client construction boilerplate. ```ts import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; -import { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; +import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; export async function main(params) { - const client = await getCommerceClient(resolveAuthParams(params)); - const products = await client.get("rest/V1/products").json(); + const client = await getCommerceClient(resolveImsAuthParams(params)); + const products = await client.get("products").json(); } ``` @@ -77,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 @@ -147,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; }; ``` @@ -173,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 @@ -223,27 +226,31 @@ 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 Commerce auth credentials. + * @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( - auth: CommerceHttpClientParams["auth"], + auth: ImsAuthParams | ImsAuthProvider, + fetchOptions?: CommerceHttpClientParams["fetchOptions"], ): Promise; ``` The base URL and flavor come from the stored association data (`getCommerceInstance`); only -the auth credentials are supplied by the caller, already resolved. Auth is resolved outside -the helper with `resolveAuthParams` from `@adobe/aio-commerce-lib-auth`, keeping -`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. +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 { resolveAuthParams } from "@adobe/aio-commerce-lib-auth"; +import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; -const client = await getCommerceClient(resolveAuthParams(params)); +const client = await getCommerceClient(resolveImsAuthParams(params)); ``` This eliminates the repeated boilerplate of combining stored instance details with the