diff --git a/packages/aio-commerce-lib-app/source/actions/config.ts b/packages/aio-commerce-lib-app/source/actions/config.ts index 00c968e8d..4e3411a94 100644 --- a/packages/aio-commerce-lib-app/source/actions/config.ts +++ b/packages/aio-commerce-lib-app/source/actions/config.ts @@ -12,6 +12,8 @@ import { byScopeId, + filterBusinessConfigSchemaByContext, + filterBusinessConfigSchemaByFlavor, getConfiguration, initialize, setConfiguration, @@ -42,6 +44,13 @@ type ConfigActionFactoryArgs = { type ConfigActionParams = RuntimeActionParams & ConfigActionFactoryArgs & { AIO_COMMERCE_CONFIG_ENCRYPTION_KEY?: string; + AIO_COMMERCE_API_FLAVOR?: unknown; + AIO_COMMERCE_API_BASE_URL?: unknown; + commerceEnv?: unknown; + commerceBaseUrl?: unknown; + commerceUrl?: unknown; + commerceInstanceUrl?: unknown; + instanceUrl?: unknown; }; /** The context for the config action. */ @@ -52,6 +61,31 @@ interface ConfigActionContext extends BaseContext { // Placeholder value for password fields. const MASKED_PASSWORD_VALUE = "*****"; +/** Normalizes a possible flavor value to a supported Commerce flavor. */ +function normalizeCommerceFlavor(value: unknown): "paas" | "saas" | undefined { + if (typeof value !== "string") { + return; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === "paas" || normalized === "saas") { + return normalized; + } + + return; +} + +/** Picks the first non-empty string value from a list of candidates. */ +function firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + + return; +} + /** * Filters password fields from the configuration values. * @param schema - The schema to use to filter the values. @@ -79,6 +113,8 @@ const router = new HttpActionRouter().use(logger()); router.get("/", { query: v.object({ scopeId: nonEmptyStringValueSchema("scopeId"), + commerceEnv: v.optional(v.picklist(["paas", "saas"] as const)), + commerceBaseUrl: v.optional(nonEmptyStringValueSchema("commerceBaseUrl")), }), handler: async (req, ctx) => { @@ -91,7 +127,28 @@ router.get("/", { "businessConfig.schema", ); - initialize({ schema: validatedSchema }); + const explicitFlavor = + req.query.commerceEnv ?? normalizeCommerceFlavor(rawParams.commerceEnv); + const commerceEnvAsBaseUrl = + explicitFlavor === undefined ? rawParams.commerceEnv : undefined; + + const filteredSchema = explicitFlavor + ? filterBusinessConfigSchemaByFlavor(validatedSchema, explicitFlavor) + : filterBusinessConfigSchemaByContext(validatedSchema, { + AIO_COMMERCE_API_FLAVOR: + rawParams.AIO_COMMERCE_API_FLAVOR ?? rawParams.commerceEnv, + AIO_COMMERCE_API_BASE_URL: firstNonEmptyString( + rawParams.AIO_COMMERCE_API_BASE_URL, + req.query.commerceBaseUrl, + rawParams.commerceBaseUrl, + rawParams.commerceUrl, + rawParams.commerceInstanceUrl, + rawParams.instanceUrl, + commerceEnvAsBaseUrl, + ), + }); + + initialize({ schema: filteredSchema }); const { scopeId } = req.query; logger.debug(`Retrieving configuration with scope id: ${scopeId}`); @@ -101,12 +158,12 @@ router.get("/", { logger.debug("Masking password values..."); appConfiguration.config = filterPasswordFields( - configSchema, + filteredSchema, appConfiguration.config, ); return ok({ - body: { schema: validatedSchema, values: appConfiguration }, + body: { schema: filteredSchema, values: appConfiguration }, }); }, }); diff --git a/packages/aio-commerce-lib-config/docs/usage.md b/packages/aio-commerce-lib-config/docs/usage.md index 28e4f32b1..287e8701b 100644 --- a/packages/aio-commerce-lib-config/docs/usage.md +++ b/packages/aio-commerce-lib-config/docs/usage.md @@ -305,19 +305,6 @@ Use text fields for free-form input values like merchant identifiers or custom s } ``` -**Boolean Field:** - -Use boolean fields for flag-like fields that only accept true/false values. Boolean fields support optional default values. - -```javascript -{ - name: "enabled", - label: "Enable Feature", - type: "boolean", - default: true -} -``` - **Email Field:** Use email fields for email addresses with automatic validation. Email fields support optional default values. @@ -419,6 +406,44 @@ Multiple selection example: > [!NOTE] > For `selectionMode: "multiple"`, the `default` value must be an array of strings, even if only one option is selected by default. +### Conditional Fields by Commerce Flavor + +Each schema field accepts an optional `env` property to scope it to specific Commerce flavors. The property is an array of flavors (`"paas"`, `"saas"`) and supports any combination of one or more values. + +When `env` is omitted, the field applies to all flavors. When `env` is provided, the field is only relevant for the listed flavors and can be filtered out for the others — for example, when rendering the configuration form in the App Management UI for an app associated with a SaaS Commerce instance. + +```javascript +{ + name: "commerceTenantId", + label: "Commerce Tenant ID", + type: "text", + env: ["saas"] +}, +{ + name: "magentoCloudProjectId", + label: "Magento Cloud Project ID", + type: "text", + env: ["paas"] +}, +{ + name: "sharedApiKey", + label: "Shared API Key", + type: "password" + // No `env` -> applies to all flavors +} +``` + +Use `filterBusinessConfigSchemaByFlavor` to keep only the fields applicable to a given flavor: + +```typescript +import { filterBusinessConfigSchemaByFlavor } from "@adobe/aio-commerce-lib-config"; + +const saasFields = filterBusinessConfigSchemaByFlavor(schema, "saas"); +const paasFields = filterBusinessConfigSchemaByFlavor(schema, "paas"); +``` + +Fields without `env` are always included. Field order is preserved. + ## CLI Commands The library provides CLI commands to help manage encryption for password fields: diff --git a/packages/aio-commerce-lib-config/source/index.ts b/packages/aio-commerce-lib-config/source/index.ts index 7ec00b45e..b8cfd3b27 100644 --- a/packages/aio-commerce-lib-config/source/index.ts +++ b/packages/aio-commerce-lib-config/source/index.ts @@ -25,7 +25,12 @@ export { type SelectorByCodeAndLevel, type SelectorByScopeId, } from "./config-utils"; -export { SchemaBusinessConfig } from "./modules/schema"; +export { + filterBusinessConfigSchemaByContext, + filterBusinessConfigSchemaByFlavor, + resolveCommerceFlavorFromContext, + SchemaBusinessConfig, +} from "./modules/schema"; export * from "./types"; export { generateEncryptionKey, @@ -39,5 +44,6 @@ export type { BusinessConfigSchemaField, BusinessConfigSchemaListOption, BusinessConfigSchemaValue, + CommerceFlavor, } from "./modules/schema"; export type { ScopeNode, ScopeTree } from "./modules/scope-tree"; diff --git a/packages/aio-commerce-lib-config/source/modules/schema/fields.ts b/packages/aio-commerce-lib-config/source/modules/schema/fields.ts index 68287c0c8..17c83ec67 100644 --- a/packages/aio-commerce-lib-config/source/modules/schema/fields.ts +++ b/packages/aio-commerce-lib-config/source/modules/schema/fields.ts @@ -16,6 +16,27 @@ const DEFAULT_BOOLEAN_VALUE = false as const; const DEFAULT_STRING_VALUE = "" as const; const DEFAULT_MULTIPLE_LIST_VALUE = [] as const; +/** The list of supported Commerce flavors a configuration field can be scoped to. */ +export const COMMERCE_FLAVORS = ["paas", "saas"] as const; + +/** Schema for a single Commerce flavor a configuration field can be scoped to. */ +const CommerceFlavorSchema = v.picklist( + COMMERCE_FLAVORS, + `Expected one of: ${COMMERCE_FLAVORS.map((f) => `"${f}"`).join(", ")}`, +); + +/** + * Schema for the optional `env` property used to scope a configuration field to + * specific Commerce flavors. When omitted, the field applies to all flavors. + */ +const EnvSchema = v.pipe( + v.array( + CommerceFlavorSchema, + "Expected an array of Commerce flavors for the field env", + ), + v.nonEmpty("The env array must contain at least one Commerce flavor"), +); + /** Base schema for configuration field options with name, optional label, and optional description */ const BaseOptionSchema = v.object({ name: v.pipe( @@ -26,6 +47,7 @@ const BaseOptionSchema = v.object({ description: v.optional( v.string("Expected a string for the field description"), ), + env: v.optional(EnvSchema), }); /** Schema for a single option in a list field, containing a display label and a value */ diff --git a/packages/aio-commerce-lib-config/source/modules/schema/index.ts b/packages/aio-commerce-lib-config/source/modules/schema/index.ts index 3e6ca37b7..d6e63d2a3 100644 --- a/packages/aio-commerce-lib-config/source/modules/schema/index.ts +++ b/packages/aio-commerce-lib-config/source/modules/schema/index.ts @@ -14,6 +14,12 @@ import * as v from "valibot"; import { SchemaBusinessConfigSchema } from "./fields"; +export { + filterBusinessConfigSchemaByContext, + filterBusinessConfigSchemaByFlavor, + resolveCommerceFlavorFromContext, +} from "./utils"; + /** The schema used to validate the the business configuration settings. */ export const SchemaBusinessConfig = v.object({ schema: v.optional(SchemaBusinessConfigSchema, []), diff --git a/packages/aio-commerce-lib-config/source/modules/schema/types.ts b/packages/aio-commerce-lib-config/source/modules/schema/types.ts index b1828011e..31ecda2ed 100644 --- a/packages/aio-commerce-lib-config/source/modules/schema/types.ts +++ b/packages/aio-commerce-lib-config/source/modules/schema/types.ts @@ -11,7 +11,17 @@ */ import type * as v from "valibot"; -import type { FieldSchema, SchemaBusinessConfigSchema } from "./fields"; +import type { + COMMERCE_FLAVORS, + FieldSchema, + SchemaBusinessConfigSchema, +} from "./fields"; + +/** + * The flavor of an Adobe Commerce application a configuration field can be + * scoped to via the `env` property. + */ +export type CommerceFlavor = (typeof COMMERCE_FLAVORS)[number]; /** Context needed for schema operations. */ export type SchemaContext = { diff --git a/packages/aio-commerce-lib-config/source/modules/schema/utils.ts b/packages/aio-commerce-lib-config/source/modules/schema/utils.ts index aeffc953b..c7ac60114 100644 --- a/packages/aio-commerce-lib-config/source/modules/schema/utils.ts +++ b/packages/aio-commerce-lib-config/source/modules/schema/utils.ts @@ -17,7 +17,65 @@ import stringify from "safe-stable-stringify"; import { SchemaBusinessConfig } from "./index"; -import type { BusinessConfigSchema } from "./types"; +import type { BusinessConfigSchema, CommerceFlavor } from "./types"; + +/** A regex matching a regular SaaS API URL, with a tenant ID and optional trailing slash. */ +const COMMERCE_SAAS_API_URL_REGEX = + /^([a-zA-Z0-9-]+\.)?api\.commerce\.adobe\.com\/[a-zA-Z0-9-]+\/?$/; + +/** Accepts base64 and base64url-safe encoded strings. */ +const BASE64_LIKE_REGEX = /^[A-Za-z0-9+/_=-]+$/; + +/** Matches an http(s) URL prefix. */ +const HTTP_URL_PREFIX_REGEX = /^https?:\/\//i; + +/** Runtime context keys used to resolve the Commerce flavor. */ +type CommerceFlavorContext = { + AIO_COMMERCE_API_FLAVOR?: unknown; + AIO_COMMERCE_API_BASE_URL?: unknown; +}; + +/** Type guard for the supported Commerce flavor values. */ +function isCommerceFlavor(input: unknown): input is CommerceFlavor { + return input === "paas" || input === "saas"; +} + +/** Tries to decode a value that might be base64/base64url encoded URL text. */ +function decodePossibleBase64Url(value: string): string | undefined { + const candidate = value.trim(); + if (candidate.length === 0 || !BASE64_LIKE_REGEX.test(candidate)) { + return; + } + + const normalized = candidate.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + + try { + const decoded = Buffer.from(padded, "base64").toString("utf8").trim(); + + return HTTP_URL_PREFIX_REGEX.test(decoded) ? decoded : undefined; + } catch { + return; + } +} + +/** Parses a URL directly, then falls back to parsing decoded base64/base64url input. */ +function parseCommerceUrl(input: string): URL | undefined { + try { + return new URL(input); + } catch { + const decodedUrl = decodePossibleBase64Url(input); + if (decodedUrl === undefined) { + return; + } + + try { + return new URL(decodedUrl); + } catch { + return; + } + } +} /** * Calculates schema version hash from content. @@ -63,3 +121,85 @@ export function getPasswordFields(schema: BusinessConfigSchema) { .map((field) => field.name), ); } + +/** + * Filters a business configuration schema to the fields applicable to the + * given Commerce flavor. + * + * Fields without an `env` property apply to all flavors and are always + * included. Fields with an `env` array are included only when the array + * contains the given flavor. + * + * @param schema - The business configuration schema to filter. + * @param flavor - The Commerce flavor to filter by. + * @returns The schema fields applicable to the given flavor. + * + * @example + * ```ts + * filterBusinessConfigSchemaByFlavor(schema, "saas"); + * ``` + */ +export function filterBusinessConfigSchemaByFlavor( + schema: BusinessConfigSchema, + flavor: CommerceFlavor, +): BusinessConfigSchema { + return schema.filter( + (field) => field.env === undefined || field.env.includes(flavor), + ); +} + +/** + * Resolves the Commerce flavor from a runtime context object. + * + * Resolution order: + * 1. `AIO_COMMERCE_API_FLAVOR` when explicitly provided as `"paas"` or `"saas"`. + * 2. Derived from `AIO_COMMERCE_API_BASE_URL` using the Commerce SaaS URL pattern. + * + * @param context - Runtime context with optional Commerce-related inputs. + * @returns The resolved flavor, or `undefined` when flavor cannot be resolved. + */ +export function resolveCommerceFlavorFromContext( + context: CommerceFlavorContext, +): CommerceFlavor | undefined { + if (isCommerceFlavor(context.AIO_COMMERCE_API_FLAVOR)) { + return context.AIO_COMMERCE_API_FLAVOR; + } + + const baseUrl = context.AIO_COMMERCE_API_BASE_URL; + if (typeof baseUrl !== "string" || baseUrl.trim().length === 0) { + return; + } + + const parsedUrl = parseCommerceUrl(baseUrl); + if (parsedUrl === undefined) { + return; + } + + const { hostname, pathname } = parsedUrl; + const hostAndPath = `${hostname}${pathname}`; + + return COMMERCE_SAAS_API_URL_REGEX.test(hostAndPath) ? "saas" : "paas"; +} + +/** + * Filters a business configuration schema to the fields applicable to the + * Commerce flavor resolved from runtime context. + * + * When flavor cannot be resolved from context, the original schema is returned. + * + * @param schema - The business configuration schema to filter. + * @param context - Runtime context with Commerce flavor/base URL inputs. + * @returns The filtered schema when a flavor is resolved, otherwise the original schema. + */ +export function filterBusinessConfigSchemaByContext( + schema: BusinessConfigSchema, + context: CommerceFlavorContext, +): BusinessConfigSchema { + const flavor = resolveCommerceFlavorFromContext(context); + + if (flavor === undefined) { + return schema; + } + + return filterBusinessConfigSchemaByFlavor(schema, flavor); +} diff --git a/packages/aio-commerce-lib-config/test/fixtures/configuration-schema.ts b/packages/aio-commerce-lib-config/test/fixtures/configuration-schema.ts index ec9870168..718ab3fb3 100644 --- a/packages/aio-commerce-lib-config/test/fixtures/configuration-schema.ts +++ b/packages/aio-commerce-lib-config/test/fixtures/configuration-schema.ts @@ -97,6 +97,24 @@ export const VALID_CONFIGURATION = [ type: "boolean", label: "Optional Toggle", }, + { + name: "saasOnlyApiKey", + type: "password", + label: "SaaS API Key", + env: ["saas"], + }, + { + name: "paasOnlyToken", + type: "text", + label: "PaaS Token", + env: ["paas"], + }, + { + name: "bothFlavorsField", + type: "text", + label: "Both Flavors Field", + env: ["paas", "saas"], + }, ] satisfies BusinessConfigSchema; export const INVALID_CONFIGURATION = [ diff --git a/packages/aio-commerce-lib-config/test/unit/modules/schema/utils.test.ts b/packages/aio-commerce-lib-config/test/unit/modules/schema/utils.test.ts index 3f0877343..eae041076 100644 --- a/packages/aio-commerce-lib-config/test/unit/modules/schema/utils.test.ts +++ b/packages/aio-commerce-lib-config/test/unit/modules/schema/utils.test.ts @@ -12,13 +12,20 @@ import { describe, expect, test } from "vitest"; -import { validateBusinessConfigSchema } from "#modules/schema/utils"; +import { + filterBusinessConfigSchemaByContext, + filterBusinessConfigSchemaByFlavor, + resolveCommerceFlavorFromContext, + validateBusinessConfigSchema, +} from "#modules/schema/utils"; import { INVALID_CONFIGURATION, VALID_CONFIGURATION, VALID_CONFIGURATION_WITHOUT_DEFAULTS, } from "#test/fixtures/configuration-schema"; +import type { BusinessConfigSchema } from "#modules/schema/types"; + describe("schema/utils", () => { describe("validateBusinessConfigSchema", () => { test("should not throw with valid schema", () => { @@ -80,5 +87,185 @@ describe("schema/utils", () => { ]), ).toThrow(); }); + + test("should accept a field without an env property", () => { + expect(() => + validateBusinessConfigSchema([ + { name: "anyFlavor", type: "text", label: "Any Flavor" }, + ]), + ).not.toThrow(); + }); + + test.each<{ env: ("paas" | "saas")[] }>([ + { env: ["saas"] }, + { env: ["paas"] }, + { env: ["paas", "saas"] }, + ])("should accept a field with env $env", ({ env }) => { + expect(() => + validateBusinessConfigSchema([ + { name: "scoped", type: "text", label: "Scoped", env }, + ]), + ).not.toThrow(); + }); + + test("should reject an empty env array", () => { + expect(() => + validateBusinessConfigSchema([ + { name: "scoped", type: "text", label: "Scoped", env: [] }, + ]), + ).toThrow(); + }); + + test("should reject an env entry that is not a known flavor", () => { + expect(() => + validateBusinessConfigSchema([ + { + name: "scoped", + type: "text", + label: "Scoped", + env: ["onprem" as never], + }, + ]), + ).toThrow(); + }); + }); + + describe("filterBusinessConfigSchemaByFlavor", () => { + const schema = [ + { name: "shared", type: "text", label: "Shared" }, + { name: "saasOnly", type: "text", label: "SaaS Only", env: ["saas"] }, + { name: "paasOnly", type: "text", label: "PaaS Only", env: ["paas"] }, + { + name: "explicitBoth", + type: "text", + label: "Both", + env: ["paas", "saas"], + }, + ] satisfies BusinessConfigSchema; + + test("should include fields without env and SaaS-scoped fields when filtering by saas", () => { + const result = filterBusinessConfigSchemaByFlavor(schema, "saas"); + + expect(result.map((field) => field.name)).toEqual([ + "shared", + "saasOnly", + "explicitBoth", + ]); + }); + + test("should include fields without env and PaaS-scoped fields when filtering by paas", () => { + const result = filterBusinessConfigSchemaByFlavor(schema, "paas"); + + expect(result.map((field) => field.name)).toEqual([ + "shared", + "paasOnly", + "explicitBoth", + ]); + }); + + test("should preserve the order of the input schema", () => { + const result = filterBusinessConfigSchemaByFlavor(schema, "saas"); + const inputOrder = schema.map((field) => field.name); + const resultOrder = result.map((field) => field.name); + + expect(resultOrder).toEqual(inputOrder.filter((n) => n !== "paasOnly")); + }); + + test("should return an empty array when the schema is empty", () => { + expect(filterBusinessConfigSchemaByFlavor([], "saas")).toEqual([]); + }); + }); + + describe("resolveCommerceFlavorFromContext", () => { + test("should use explicit flavor when provided", () => { + expect( + resolveCommerceFlavorFromContext({ + AIO_COMMERCE_API_FLAVOR: "saas", + AIO_COMMERCE_API_BASE_URL: "https://example.invalid", + }), + ).toBe("saas"); + }); + + test("should resolve saas flavor from SaaS API URL", () => { + expect( + resolveCommerceFlavorFromContext({ + AIO_COMMERCE_API_BASE_URL: "https://api.commerce.adobe.com/tenant-id", + }), + ).toBe("saas"); + }); + + test("should resolve saas flavor from base64 encoded SaaS API URL", () => { + expect( + resolveCommerceFlavorFromContext({ + AIO_COMMERCE_API_BASE_URL: + "aHR0cHM6Ly9hcGkuY29tbWVyY2UuYWRvYmUuY29tL3RlbmFudC1pZA==", + }), + ).toBe("saas"); + }); + + test("should resolve saas flavor from base64url encoded SaaS API URL", () => { + expect( + resolveCommerceFlavorFromContext({ + AIO_COMMERCE_API_BASE_URL: + "aHR0cHM6Ly9hcGkuY29tbWVyY2UuYWRvYmUuY29tL3RlbmFudC1pZA", + }), + ).toBe("saas"); + }); + + test("should resolve paas flavor from non-SaaS API URL", () => { + expect( + resolveCommerceFlavorFromContext({ + AIO_COMMERCE_API_BASE_URL: "https://store.example.com", + }), + ).toBe("paas"); + }); + + test("should return undefined when base URL is invalid and flavor is not explicit", () => { + expect( + resolveCommerceFlavorFromContext({ + AIO_COMMERCE_API_BASE_URL: "not-a-url", + }), + ).toBeUndefined(); + }); + + test("should return undefined when context does not include flavor inputs", () => { + expect(resolveCommerceFlavorFromContext({})).toBeUndefined(); + }); + }); + + describe("filterBusinessConfigSchemaByContext", () => { + const schema = [ + { name: "shared", type: "text", label: "Shared" }, + { name: "saasOnly", type: "text", label: "SaaS Only", env: ["saas"] }, + { name: "paasOnly", type: "text", label: "PaaS Only", env: ["paas"] }, + ] satisfies BusinessConfigSchema; + + test("should filter by explicit context flavor", () => { + const result = filterBusinessConfigSchemaByContext(schema, { + AIO_COMMERCE_API_FLAVOR: "saas", + }); + + expect(result.map((field) => field.name)).toEqual(["shared", "saasOnly"]); + }); + + test("should infer flavor from base URL when explicit flavor is absent", () => { + const result = filterBusinessConfigSchemaByContext(schema, { + AIO_COMMERCE_API_BASE_URL: "https://api.commerce.adobe.com/tenant-id", + }); + + expect(result.map((field) => field.name)).toEqual(["shared", "saasOnly"]); + }); + + test("should return original schema when flavor cannot be resolved", () => { + const result = filterBusinessConfigSchemaByContext(schema, { + AIO_COMMERCE_API_BASE_URL: "not-a-url", + }); + + expect(result.map((field) => field.name)).toEqual([ + "shared", + "saasOnly", + "paasOnly", + ]); + }); }); });