From ce661de908907f704bba8d9ae16a71cfadbae20d Mon Sep 17 00:00:00 2001 From: Lars Roettig Date: Wed, 4 Mar 2026 14:59:19 +0100 Subject: [PATCH 1/6] feat(config): add configuration versioning and harden scope/config flows Introduce version history and restore support for business configuration, tighten selector resolution to prevent ambiguous scope-by-code reads, and improve persistence/error handling for consistency and safety. Also refactor generated action templates to remove duplicated audit parsing logic and add unit coverage for ambiguity, malformed snapshots, and versioning paths. --- .../plugins/selective-bundle/helpers.js | 107 ++++- .../commands/generate/actions/config.ts | 30 +- .../source/commands/generate/actions/main.ts | 12 + .../audit-enabled.js.template | 27 ++ .../get-configuration-versions.js.template | 128 ++++++ .../restore-configuration-version.js.template | 119 +++++ .../set-configuration.js.template | 4 + .../commands/generate/actions/config.test.ts | 44 ++ .../source/commands/index.ts | 5 + .../source/config-manager.ts | 246 ++++++++++- .../source/config-utils.ts | 29 +- .../configuration/configuration-repository.ts | 73 +++- .../configuration/get-config-by-key.ts | 2 +- .../modules/configuration/set-config.ts | 5 +- .../source/modules/configuration/types.ts | 2 + .../modules/versioning/version-repository.ts | 413 ++++++++++++++++++ .../source/types/api.ts | 92 ++++ .../source/types/index.ts | 3 + .../test/mocks/lib-files.ts | 15 +- .../test/unit/config-manager.test.ts | 230 +++++++++- 20 files changed, 1520 insertions(+), 66 deletions(-) create mode 100644 packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/audit-enabled.js.template create mode 100644 packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template create mode 100644 packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template create mode 100644 packages/aio-commerce-lib-app/test/unit/commands/generate/actions/config.test.ts create mode 100644 packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts diff --git a/configs/tsdown/plugins/selective-bundle/helpers.js b/configs/tsdown/plugins/selective-bundle/helpers.js index 58f327ce8..cdaeec45c 100644 --- a/configs/tsdown/plugins/selective-bundle/helpers.js +++ b/configs/tsdown/plugins/selective-bundle/helpers.js @@ -17,10 +17,100 @@ import { realpathSync, writeFileSync, } from "node:fs"; -import { resolve, sep } from "node:path"; +import { dirname, resolve, sep } from "node:path"; import { PRIVATE_DEPS_LOOKUP } from "./constants.js"; +const CATALOG_HEADER_REGEX = /^catalog:\s*$/; +const LEADING_WHITESPACE_REGEX = /^\s/; +const COMMENT_LINE_REGEX = /^\s*#/; +const CATALOG_ENTRY_REGEX = /^\s+(.+?)\s*:\s*(.+)$/; + +/** + * Walks up the directory tree from `startDir` to find `pnpm-workspace.yaml`. + * @param {string} startDir + * @returns {string | null} + */ +function findWorkspaceYaml(startDir) { + let dir = startDir; + while (true) { + const candidate = resolve(dir, "pnpm-workspace.yaml"); + if (existsSync(candidate)) { + return candidate; + } + const parent = dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +/** + * Parses the default `catalog:` block from a pnpm-workspace.yaml file. + * Does not require an external YAML library — only handles the simple + * flat key-value catalog structure used in this repo. + * + * @param {string} yamlPath + * @returns {Record} + */ +function loadPnpmCatalog(yamlPath) { + const catalog = {}; + const lines = readFileSync(yamlPath, "utf-8").split("\n"); + let inCatalog = false; + + for (const line of lines) { + if (CATALOG_HEADER_REGEX.test(line)) { + inCatalog = true; + continue; + } + + if (inCatalog) { + // A non-empty, non-comment line without leading whitespace ends the block + if ( + line.length > 0 && + !LEADING_WHITESPACE_REGEX.test(line) && + !COMMENT_LINE_REGEX.test(line) + ) { + inCatalog = false; + continue; + } + + // Parse " 'key': value" or " key: value" + const match = CATALOG_ENTRY_REGEX.exec(line.trimEnd()); + if (match) { + const name = match[1].replace(/^['"]|['"]$/g, "").trim(); + catalog[name] = match[2].trim(); + } + } + } + + return catalog; +} + +/** + * Resolves `catalog:` protocol references in a deps object to their + * actual semver ranges from the workspace catalog. + * + * @param {Record | undefined} deps + * @param {Record} catalog + * @returns {Record | undefined} + */ +function resolveCatalogRefs(deps, catalog) { + if (!deps) { + return deps; + } + + return Object.fromEntries( + Object.entries(deps).map(([name, version]) => { + if (version === "catalog:" || version.startsWith("catalog:")) { + return [name, catalog[name] ?? version]; + } + return [name, version]; + }), + ); +} + /** * Gets the bare module name from an import source string. * @param {string} source @@ -110,10 +200,23 @@ export function buildEnrichedPackageJson(packageRoot, manifest) { readFileSync(resolve(packageRoot, "package.json"), "utf-8"), ); + // Resolve catalog: protocol references so the packed tarball contains + // plain semver ranges that npm/yarn/bun can understand. + const workspaceYamlPath = findWorkspaceYaml(packageRoot); + const catalog = workspaceYamlPath ? loadPnpmCatalog(workspaceYamlPath) : {}; + pkg.dependencies = resolveCatalogRefs(pkg.dependencies, catalog); + pkg.peerDependencies = resolveCatalogRefs(pkg.peerDependencies, catalog); + pkg.devDependencies = resolveCatalogRefs(pkg.devDependencies, catalog); + pkg.dependencies ??= {}; for (const [name, info] of Object.entries(manifest)) { - pkg.dependencies[name] ??= info.version; + // Resolve catalog: in transitive deps from private packages before merging + const version = + info.version === "catalog:" || info.version.startsWith("catalog:") + ? (catalog[name] ?? info.version) + : info.version; + pkg.dependencies[name] ??= version; } pkg.dependencies = Object.fromEntries( 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 a5ae86b45..83de54ee2 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 @@ -28,6 +28,7 @@ import type { CommerceAppConfigDomain } from "#config/schema/domains"; type ActionConfig = { requiresSchema?: boolean; requiresEncryptionKey?: boolean; + requiresAuditFlag?: boolean; }; export type TemplateAction = ActionConfig & { @@ -64,7 +65,7 @@ function createActionDefinition( actionName: string, config: ActionConfig = {}, options: Omit = {}, -) { +): ActionDefinition { const def: ActionDefinition = { ...options, @@ -90,6 +91,13 @@ function createActionDefinition( }; } + if (config.requiresAuditFlag) { + def.inputs = { + ...def.inputs, + AIO_COMMERCE_CONFIG_AUDIT_ENABLED: "$AIO_COMMERCE_CONFIG_AUDIT_ENABLED", + }; + } + return def; } @@ -97,7 +105,10 @@ function createActionDefinition( * Gets the runtime actions to be generated from the ext.config.yaml configuration. * @param extConfig - The ext.config.yaml configuration. */ -export function getRuntimeActions(extConfig: ExtConfig, dir: string) { +export function getRuntimeActions( + extConfig: ExtConfig, + dir: string, +): TemplateAction[] { return Object.entries( extConfig.runtimeManifest?.packages?.[PACKAGE_NAME]?.actions ?? {}, ).map( @@ -115,7 +126,7 @@ export function getRuntimeActions(extConfig: ExtConfig, dir: string) { */ export function buildAppManagementExtConfig( features: Set, -) { +): ExtConfig { const extConfig = { hooks: { "pre-app-build": @@ -174,7 +185,7 @@ export function buildAppManagementExtConfig( } /** Builds the ext.config.yaml configuration for the business configuration extension. */ -export function buildBusinessConfigurationExtConfig() { +export function buildBusinessConfigurationExtConfig(): ExtConfig { const actions = [ { name: "get-scope-tree", @@ -195,6 +206,17 @@ export function buildBusinessConfigurationExtConfig() { name: "set-configuration", templateFile: "set-configuration.js.template", requiresEncryptionKey: true, + requiresAuditFlag: true, + }, + { + name: "get-configuration-versions", + templateFile: "get-configuration-versions.js.template", + requiresAuditFlag: true, + }, + { + name: "restore-configuration-version", + templateFile: "restore-configuration-version.js.template", + requiresAuditFlag: true, }, { name: "set-custom-scope-tree", diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/main.ts b/packages/aio-commerce-lib-app/source/commands/generate/actions/main.ts index 0a9de718c..5157ea193 100644 --- a/packages/aio-commerce-lib-app/source/commands/generate/actions/main.ts +++ b/packages/aio-commerce-lib-app/source/commands/generate/actions/main.ts @@ -153,6 +153,18 @@ async function generateActionFiles( const outputFiles: string[] = []; const templatesDir = join(__dirname, "generate/actions/templates"); + if (extensionPointId === CONFIGURATION_EXTENSION_POINT_ID) { + const sharedTemplatePath = join( + templatesDir, + "business-configuration", + "audit-enabled.js.template", + ); + const sharedOutputPath = join(outputDir, "audit-enabled.js"); + const sharedTemplate = await readFile(sharedTemplatePath, "utf-8"); + await writeFile(sharedOutputPath, sharedTemplate, "utf-8"); + outputFiles.push(` ${relative(process.cwd(), sharedOutputPath)}`); + } + for (const action of actions) { const templatePath = join(templatesDir, action.templateFile); let template = await readFile(templatePath, "utf-8"); diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/audit-enabled.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/audit-enabled.js.template new file mode 100644 index 000000000..a87450c15 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/audit-enabled.js.template @@ -0,0 +1,27 @@ +/* + * 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. + */ + +export function parseAuditEnabled(value) { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } + } + return true; +} diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template new file mode 100644 index 000000000..5e103d5bc --- /dev/null +++ b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template @@ -0,0 +1,128 @@ +/* + * 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-config` +// Do not modify this file directly + +import util from "node:util"; + +import { + byCode, + byCodeAndLevel, + byScopeId, + getConfigurationVersions, + setGlobalLibConfigOptions, +} from "@adobe/aio-commerce-lib-config"; +import { parseAuditEnabled } from "./audit-enabled.js"; +import { + badRequest, + internalServerError, + ok, +} from "@adobe/aio-commerce-sdk/core/responses"; +import AioLogger from "@adobe/aio-lib-core-logging"; + +const inspect = (obj) => util.inspect(obj, { depth: null }); + +function parsePositiveInteger(value) { + if (value === undefined || value === null || value === "") { + return undefined; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + return null; + } + return parsed; +} + +/** + * Get configuration version history. + * @param params - The input parameters. + * @returns The response object containing version history. + */ +export async function main(params) { + const logger = AioLogger("get-configuration-versions", { + level: params.LOG_LEVEL || "info", + }); + + try { + const auditEnabled = parseAuditEnabled(params.AIO_COMMERCE_CONFIG_AUDIT_ENABLED); + setGlobalLibConfigOptions({ auditEnabled }); + if (!auditEnabled) { + return badRequest({ + body: { + code: "AUDIT_DISABLED", + message: "Audit feature is disabled", + }, + }); + } + + const id = params.id; + const code = params.code; + const level = params.level; + const limit = parsePositiveInteger(params.limit); + const offset = parsePositiveInteger(params.offset); + + if (limit === null || offset === null) { + return badRequest({ + body: { + code: "INVALID_PARAMS", + message: "limit and offset must be positive integers", + }, + }); + } + + if (!(id || code)) { + return badRequest({ + body: { + code: "INVALID_PARAMS", + message: "Either id or code query param is required", + }, + }); + } + + const selector = id + ? byScopeId(id) + : level + ? byCodeAndLevel(code, level) + : byCode(code); + const result = await getConfigurationVersions(selector, { limit, offset }); + + return ok({ + body: result, + headers: { + "Cache-Control": "no-store", + }, + }); + } catch (error) { + logger.error( + `Something went wrong while retrieving configuration versions: ${inspect(error)}`, + ); + + if (error instanceof Error && error.message.includes("AUDIT_DISABLED")) { + return badRequest({ + body: { + code: "AUDIT_DISABLED", + message: "Audit feature is disabled", + }, + }); + } + + return internalServerError({ + body: { + code: "INTERNAL_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + message: "An internal server error occurred", + }, + }); + } +} diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template new file mode 100644 index 000000000..1f20c3cd0 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template @@ -0,0 +1,119 @@ +/* + * 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-config` +// Do not modify this file directly + +import util from "node:util"; + +import { + byCodeAndLevel, + byScopeId, + restoreConfigurationVersion, + setGlobalLibConfigOptions, +} from "@adobe/aio-commerce-lib-config"; +import { parseAuditEnabled } from "./audit-enabled.js"; +import { + badRequest, + internalServerError, + ok, +} from "@adobe/aio-commerce-sdk/core/responses"; +import AioLogger from "@adobe/aio-lib-core-logging"; + +const inspect = (obj) => util.inspect(obj, { depth: null }); + +/** + * Restore configuration from a version. + * @param params - The input parameters. + * @returns The response object containing restore details. + */ +export async function main(params) { + const logger = AioLogger("restore-configuration-version", { + level: params.LOG_LEVEL || "info", + }); + + try { + const auditEnabled = parseAuditEnabled(params.AIO_COMMERCE_CONFIG_AUDIT_ENABLED); + setGlobalLibConfigOptions({ auditEnabled }); + if (!auditEnabled) { + return badRequest({ + body: { + code: "AUDIT_DISABLED", + message: "Audit feature is disabled", + }, + }); + } + + const id = params.id; + const code = params.code; + const level = params.level; + const versionId = params.versionId; + const expectedLatestVersionId = params.expectedLatestVersionId; + + if (!(id || (code && level))) { + return badRequest({ + body: { + code: "INVALID_PARAMS", + message: "Either id or both code and level query params are required", + }, + }); + } + + if (!(typeof versionId === "string" && versionId.trim().length > 0)) { + return badRequest({ + body: { + code: "INVALID_BODY", + message: "versionId is required", + }, + }); + } + + const selector = id ? byScopeId(id) : byCodeAndLevel(code, level); + const result = await restoreConfigurationVersion(selector, { + versionId, + expectedLatestVersionId, + }); + + return ok({ + body: result, + headers: { + "Cache-Control": "no-store", + }, + }); + } catch (error) { + logger.error( + `Something went wrong while restoring configuration version: ${inspect(error)}`, + ); + + if ( + error instanceof Error && + (error.message.includes("AUDIT_DISABLED") || + error.message.includes("VERSION_NOT_FOUND") || + error.message.includes("VERSION_CONFLICT")) + ) { + return badRequest({ + body: { + code: "INVALID_REQUEST", + message: error.message, + }, + }); + } + + return internalServerError({ + body: { + code: "INTERNAL_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + message: "An internal server error occurred", + }, + }); + } +} diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template index 6827d01fb..0f662dec4 100644 --- a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template +++ b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template @@ -23,6 +23,7 @@ import { setConfiguration, setGlobalLibConfigOptions, } from "@adobe/aio-commerce-lib-config"; +import { parseAuditEnabled } from "./audit-enabled.js"; import { badRequest, internalServerError, @@ -49,6 +50,9 @@ export async function main(params) { encryptionKey: params.AIO_COMMERCE_CONFIG_ENCRYPTION_KEY, }); } + setGlobalLibConfigOptions({ + auditEnabled: parseAuditEnabled(params.AIO_COMMERCE_CONFIG_AUDIT_ENABLED), + }); let body; if ( 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 new file mode 100644 index 000000000..fa8e02a59 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/commands/generate/actions/config.test.ts @@ -0,0 +1,44 @@ +/* + * 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, it } from "vitest"; + +import { buildBusinessConfigurationExtConfig } from "#commands/generate/actions/config"; + +describe("buildBusinessConfigurationExtConfig", () => { + it("includes versions and restore actions in business configuration manifest", () => { + const extConfig = buildBusinessConfigurationExtConfig(); + const actions = + extConfig.runtimeManifest?.packages?.["app-management"]?.actions ?? {}; + + expect(actions["get-configuration-versions"]).toBeDefined(); + expect(actions["restore-configuration-version"]).toBeDefined(); + }); + + it("wires the audit feature flag input for set/versions/restore actions", () => { + const extConfig = buildBusinessConfigurationExtConfig(); + const actions = + extConfig.runtimeManifest?.packages?.["app-management"]?.actions ?? {}; + + expect( + actions["set-configuration"]?.inputs?.AIO_COMMERCE_CONFIG_AUDIT_ENABLED, + ).toBe("$AIO_COMMERCE_CONFIG_AUDIT_ENABLED"); + expect( + actions["get-configuration-versions"]?.inputs + ?.AIO_COMMERCE_CONFIG_AUDIT_ENABLED, + ).toBe("$AIO_COMMERCE_CONFIG_AUDIT_ENABLED"); + expect( + actions["restore-configuration-version"]?.inputs + ?.AIO_COMMERCE_CONFIG_AUDIT_ENABLED, + ).toBe("$AIO_COMMERCE_CONFIG_AUDIT_ENABLED"); + }); +}); diff --git a/packages/aio-commerce-lib-config/source/commands/index.ts b/packages/aio-commerce-lib-config/source/commands/index.ts index 2c7f485b8..89c4fc280 100644 --- a/packages/aio-commerce-lib-config/source/commands/index.ts +++ b/packages/aio-commerce-lib-config/source/commands/index.ts @@ -42,6 +42,11 @@ const COMMANDS = { encryption: { setup: encryptionSetupCommand, }, + // TODO: Remove `validate schema` once encryption setup is split out of schema generation. + // Currently `generate schema` in aio-commerce-lib-app calls this as a combined step. + validate: { + schema: encryptionSetupCommand, + }, } as const; /** diff --git a/packages/aio-commerce-lib-config/source/config-manager.ts b/packages/aio-commerce-lib-config/source/config-manager.ts index 98b37f1e6..1738adb7d 100644 --- a/packages/aio-commerce-lib-config/source/config-manager.ts +++ b/packages/aio-commerce-lib-config/source/config-manager.ts @@ -10,6 +10,10 @@ * governing permissions and limitations under the License. */ +import { + deriveScopeFromArgs, + deriveScopeFromCodeAndLevel, +} from "#config-utils"; import { DEFAULT_CACHE_TIMEOUT, DEFAULT_NAMESPACE } from "#utils/constants"; import { @@ -17,6 +21,7 @@ import { getConfiguration as getConfigModule, setConfiguration as setConfigModule, } from "./modules/configuration"; +import * as configRepository from "./modules/configuration/configuration-repository"; import { getSchema as getSchemaModule } from "./modules/schema"; import { getPersistedScopeTree, @@ -24,20 +29,43 @@ import { saveScopeTree, setCustomScopeTree as setCustomScopeTreeModule, } from "./modules/scope-tree"; +import { + getVersionRecord, + listVersionRecords, +} from "./modules/versioning/version-repository"; import type { CommerceHttpClientParams } from "@adobe/aio-commerce-lib-api"; import type { SelectorBy } from "#config-utils"; -import type { GetScopeTreeResult, ScopeTree } from "./modules/scope-tree"; import type { + ConfigContext, + ConfigValue, + ConfigValueWithOptionalOrigin, +} from "./modules/configuration/types"; +import type { BusinessConfigSchema } from "./modules/schema"; +import type { + GetScopeTreeResult, + ScopeNode, + ScopeTree, +} from "./modules/scope-tree"; +import type { + GetConfigurationByKeyResponse, + GetConfigurationResponse, + GetConfigurationVersionsParams, + GetConfigurationVersionsResponse, GlobalLibConfigOptions, LibConfigOptions, + RestoreConfigurationVersionRequest, + RestoreConfigurationVersionResponse, SetConfigurationRequest, + SetConfigurationResponse, SetCustomScopeTreeRequest, + SetCustomScopeTreeResponse, } from "./types"; const globalLibConfigOptions: GlobalLibConfigOptions = { cacheTimeout: DEFAULT_CACHE_TIMEOUT, encryptionKey: undefined, + auditEnabled: true, }; /** @@ -66,6 +94,8 @@ export function setGlobalLibConfigOptions(options: LibConfigOptions) { options.encryptionKey !== undefined ? options.encryptionKey : globalLibConfigOptions.encryptionKey; + globalLibConfigOptions.auditEnabled = + options.auditEnabled ?? globalLibConfigOptions.auditEnabled; } /** @@ -77,6 +107,77 @@ export function getGlobalLibConfigOptions(): GlobalLibConfigOptions { return globalLibConfigOptions; } +function resolveConfigContext(options?: LibConfigOptions): ConfigContext { + return { + namespace: DEFAULT_NAMESPACE, + cacheTimeout: options?.cacheTimeout ?? globalLibConfigOptions.cacheTimeout, + auditEnabled: options?.auditEnabled ?? globalLibConfigOptions.auditEnabled, + }; +} + +function assertAuditEnabled(auditEnabled: boolean): void { + if (!auditEnabled) { + throw new Error("AUDIT_DISABLED: audit feature is disabled"); + } +} + +async function resolveScopeForSelector(selector: SelectorBy): Promise<{ + scopeCode: string; + scopeLevel: string; + scopeId: string; + scopePath: ScopeNode[]; +}> { + const scopeTree = await getPersistedScopeTree(DEFAULT_NAMESPACE); + if (selector.by._tag === "scopeId") { + return deriveScopeFromArgs([selector.by.scopeId], scopeTree); + } + if (selector.by._tag === "codeAndLevel") { + return deriveScopeFromArgs( + [selector.by.code, selector.by.level], + scopeTree, + ); + } + + const matches = findScopesByCode(scopeTree, selector.by.code); + if (matches.length === 0) { + throw new Error(`INVALID_SCOPE: Unknown scope code='${selector.by.code}'`); + } + if (matches.length > 1) { + throw new Error( + `AMBIGUOUS_SCOPE_CODE: Multiple scopes found for code='${selector.by.code}', provide level`, + ); + } + + return deriveScopeFromCodeAndLevel( + selector.by.code, + matches[0].level, + scopeTree, + ); +} + +function findScopesByCode( + scopeTree: ScopeTree, + scopeCode: string, +): Array<{ level: string }> { + const matches: Array<{ level: string }> = []; + const visit = (scope: ScopeTree[number]) => { + if (scope.code === scopeCode) { + matches.push({ level: scope.level }); + } + if (scope.children) { + for (const child of scope.children) { + visit(child); + } + } + }; + + for (const root of scopeTree) { + visit(root); + } + + return matches; +} + /** Parameters for getting the scope tree from Commerce API. */ export type GetFreshScopeTreeParams = { refreshData: true; @@ -156,7 +257,7 @@ export async function getScopeTree( export async function getScopeTree( params?: GetCachedScopeTreeParams | GetFreshScopeTreeParams, options?: LibConfigOptions, -) { +): Promise { const context = { namespace: DEFAULT_NAMESPACE, cacheTimeout: options?.cacheTimeout ?? globalLibConfigOptions.cacheTimeout, @@ -213,7 +314,11 @@ export async function getScopeTree( export async function syncCommerceScopes( commerceConfig: CommerceHttpClientParams, options?: LibConfigOptions, -) { +): Promise<{ + scopeTree: ScopeTree; + synced: boolean; + error?: string; +}> { try { const result = await getScopeTree( { @@ -321,7 +426,9 @@ export async function unsyncCommerceScopes(): Promise { * } * ``` */ -export function getConfigSchema(options?: LibConfigOptions) { +export function getConfigSchema( + options?: LibConfigOptions, +): Promise { const context = { namespace: DEFAULT_NAMESPACE, cacheTimeout: options?.cacheTimeout ?? globalLibConfigOptions.cacheTimeout, @@ -363,11 +470,8 @@ export function getConfigSchema(options?: LibConfigOptions) { export async function getConfiguration( selector: SelectorBy, options?: LibConfigOptions, -) { - const context = { - namespace: DEFAULT_NAMESPACE, - cacheTimeout: options?.cacheTimeout ?? globalLibConfigOptions.cacheTimeout, - }; +): Promise { + const context = resolveConfigContext(options); if (selector.by._tag === "scopeId") { return await getConfigModule(context, selector.by.scopeId); @@ -377,7 +481,8 @@ export async function getConfiguration( return await getConfigModule(context, selector.by.code, selector.by.level); } - return await getConfigModule(context, selector.by.code); + const resolvedScope = await resolveScopeForSelector(selector); + return await getConfigModule(context, resolvedScope.scopeId); } /** @@ -414,11 +519,8 @@ export async function getConfigurationByKey( configKey: string, selector: SelectorBy, options?: LibConfigOptions, -) { - const context = { - namespace: DEFAULT_NAMESPACE, - cacheTimeout: options?.cacheTimeout ?? globalLibConfigOptions.cacheTimeout, - }; +): Promise { + const context = resolveConfigContext(options); if (selector.by._tag === "scopeId") { return await getConfigByKeyModule(context, configKey, selector.by.scopeId); @@ -433,7 +535,8 @@ export async function getConfigurationByKey( ); } - return await getConfigByKeyModule(context, configKey, selector.by.code); + const resolvedScope = await resolveScopeForSelector(selector); + return await getConfigByKeyModule(context, configKey, resolvedScope.scopeId); } /** @@ -483,11 +586,8 @@ export async function setConfiguration( request: SetConfigurationRequest, selector: SelectorBy, options?: LibConfigOptions, -) { - const context = { - namespace: DEFAULT_NAMESPACE, - cacheTimeout: options?.cacheTimeout ?? globalLibConfigOptions.cacheTimeout, - }; +): Promise { + const context = resolveConfigContext(options); if (selector.by._tag === "scopeId") { return await setConfigModule(context, request, selector.by.scopeId); @@ -502,7 +602,107 @@ export async function setConfiguration( ); } - return await setConfigModule(context, request, selector.by.code); + const resolvedScope = await resolveScopeForSelector(selector); + return await setConfigModule(context, request, resolvedScope.scopeId); +} + +/** + * Lists configuration versions for a scope. + */ +export async function getConfigurationVersions( + selector: SelectorBy, + params?: GetConfigurationVersionsParams, + options?: LibConfigOptions, +): Promise { + const context = resolveConfigContext(options); + assertAuditEnabled(context.auditEnabled); + + const resolvedScope = await resolveScopeForSelector(selector); + const page = await listVersionRecords(resolvedScope.scopeCode, params); + return { + scope: { + id: resolvedScope.scopeId, + code: resolvedScope.scopeCode, + level: resolvedScope.scopeLevel, + }, + versions: page.versions, + pagination: { + total: page.total, + limit: page.limit, + offset: page.offset, + }, + }; +} + +/** + * Restores configuration for a scope from a specific historical version. + */ +export async function restoreConfigurationVersion( + selector: SelectorBy, + request: RestoreConfigurationVersionRequest, + options?: LibConfigOptions, +): Promise { + const context = resolveConfigContext(options); + assertAuditEnabled(context.auditEnabled); + + const resolvedScope = await resolveScopeForSelector(selector); + const targetVersion = await getVersionRecord( + resolvedScope.scopeCode, + request.versionId, + ); + + if (!targetVersion) { + throw new Error(`VERSION_NOT_FOUND: ${request.versionId}`); + } + + const payload = { + scope: { + id: resolvedScope.scopeId, + code: resolvedScope.scopeCode, + level: resolvedScope.scopeLevel, + }, + config: extractConfigSnapshot(targetVersion.snapshot), + }; + const restoredVersion = await configRepository.persistConfig( + resolvedScope.scopeCode, + payload, + { + auditEnabled: true, + reason: "restore", + restoredFromVersionId: request.versionId, + expectedLatestVersionId: request.expectedLatestVersionId, + }, + ); + if (!restoredVersion) { + throw new Error("RESTORE_FAILED: could not create restore version"); + } + + return { + message: "Configuration version restored successfully", + timestamp: new Date().toISOString(), + scope: { + id: resolvedScope.scopeId, + code: resolvedScope.scopeCode, + level: resolvedScope.scopeLevel, + }, + restoredVersionId: restoredVersion.id, + }; +} + +function extractConfigSnapshot( + snapshot: unknown, +): ConfigValueWithOptionalOrigin[] | ConfigValue[] { + if ( + typeof snapshot !== "object" || + snapshot === null || + !("config" in snapshot) || + !Array.isArray(snapshot.config) + ) { + throw new Error( + "VERSION_SNAPSHOT_INVALID: missing config array in snapshot", + ); + } + return snapshot.config as ConfigValueWithOptionalOrigin[] | ConfigValue[]; } /** @@ -572,7 +772,7 @@ export async function setConfiguration( export async function setCustomScopeTree( request: SetCustomScopeTreeRequest, options?: LibConfigOptions, -) { +): Promise { const context = { namespace: DEFAULT_NAMESPACE, cacheTimeout: options?.cacheTimeout ?? globalLibConfigOptions.cacheTimeout, diff --git a/packages/aio-commerce-lib-config/source/config-utils.ts b/packages/aio-commerce-lib-config/source/config-utils.ts index caa02f046..adfb0ed1b 100644 --- a/packages/aio-commerce-lib-config/source/config-utils.ts +++ b/packages/aio-commerce-lib-config/source/config-utils.ts @@ -170,11 +170,9 @@ export function deriveScopeFromCodeWithOptionalLevel( throw new Error("INVALID_ARGS: expected (code: string)"); } - if (!isNonEmptyString(level)) { - throw new Error("INVALID_ARGS: expected (level: string)"); - } - - const effectiveLevel = level?.trim() || DEFAULT_CUSTOM_SCOPE_LEVEL; + const effectiveLevel = isNonEmptyString(level) + ? level.trim() + : DEFAULT_CUSTOM_SCOPE_LEVEL; return deriveScopeFromCodeAndLevel(code, effectiveLevel, tree); } @@ -184,21 +182,22 @@ export function deriveScopeFromCodeWithOptionalLevel( * @param tree - The scope tree to search for the node. * @returns The derived scope information including code, level, id, and path. */ -export function deriveScopeFromArgs(args: unknown[], tree: ScopeTree) { +export function deriveScopeFromArgs( + args: unknown[], + tree: ScopeTree, +): { + scopeCode: string; + scopeLevel: string; + scopeId: string; + scopePath: ScopeNode[]; +} { if (args.length === 2) { return deriveScopeFromCodeAndLevel(args[0], args[1], tree); } if (args.length === 1) { - const arg = args[0]; - // Try as ID first, then as code with default level - try { - return deriveScopeFromId(arg, tree); - } catch (_error) { - // If ID lookup fails, treat as code with default level - return deriveScopeFromCodeWithOptionalLevel(arg, undefined, tree); - } + return deriveScopeFromId(args[0], tree); } - throw new Error("INVALID_ARGS: expected (id), (code), or (code, level)"); + throw new Error("INVALID_ARGS: expected (id) or (code, level)"); } /** 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 9b75d1020..0d71fc1b4 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 @@ -10,16 +10,31 @@ * governing permissions and limitations under the License. */ +import { createVersionRecord } from "#modules/versioning/version-repository"; import { getLogger } from "#utils/logger"; import { getSharedFiles, getSharedState } from "#utils/repository"; +type PersistConfigOptions = { + auditEnabled?: boolean; + reason?: "set" | "restore"; + restoredFromVersionId?: string; + expectedLatestVersionId?: string; +}; + +type PersistedConfigPayload = { + scope?: unknown; + config?: unknown; +}; + /** * Gets cached configuration payload from state store. * * @param scopeCode - Scope code identifier. * @returns Promise resolving to cached configuration payload or null if not found. */ -export async function getCachedConfig(scopeCode: string) { +export async function getCachedConfig( + scopeCode: string, +): Promise { try { const state = await getSharedState(); const key = getConfigStateKey(scopeCode); @@ -41,7 +56,10 @@ export async function getCachedConfig(scopeCode: string) { * @param scopeCode - Scope code identifier. * @param payload - Configuration payload as JSON string. */ -export async function setCachedConfig(scopeCode: string, payload: string) { +export async function setCachedConfig( + scopeCode: string, + payload: string, +): Promise { const logger = getLogger( "@adobe/aio-commerce-lib-config:configuration-repository", ); @@ -64,20 +82,18 @@ export async function setCachedConfig(scopeCode: string, payload: string) { * @param scopeCode - Scope code identifier. * @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, +): Promise { try { const files = await getSharedFiles(); const filePath = getConfigFilePath(scopeCode); - const filesList = await files.list("scope/"); - const fileObject = filesList.find((file) => file.name === filePath); - - if (!fileObject) { - return null; - } - const content = await files.read(filePath); return content ? content.toString("utf8") : null; - } catch (_) { + } catch (error) { + if (isNotFoundError(error)) { + return null; + } return null; } } @@ -88,7 +104,10 @@ export async function getPersistedConfig(scopeCode: string) { * @param scopeCode - Scope code identifier. * @param payload - Configuration payload as JSON string. */ -export async function saveConfig(scopeCode: string, payload: string) { +export async function saveConfig( + scopeCode: string, + payload: string, +): Promise { const files = await getSharedFiles(); const filePath = getConfigFilePath(scopeCode); await files.write(filePath, payload); @@ -100,7 +119,11 @@ export async function saveConfig(scopeCode: string, payload: string) { * @param scopeCode - The scope code to persist configuration for. * @param payload - The configuration payload object. */ -export async function persistConfig(scopeCode: string, payload: unknown) { +export async function persistConfig( + scopeCode: string, + payload: unknown, + options: PersistConfigOptions = {}, +): Promise<{ id: string } | null> { const payloadString = JSON.stringify(payload); const logger = getLogger( "@adobe/aio-commerce-lib-config:configuration-repository", @@ -118,6 +141,18 @@ export async function persistConfig(scopeCode: string, payload: unknown) { error: e instanceof Error ? e.message : String(e), }); } + + let createdVersionId: string | null = null; + if (options.auditEnabled !== false) { + const createdVersion = await createVersionRecord(scopeCode, payload, { + reason: options.reason ?? "set", + restoredFromVersionId: options.restoredFromVersionId, + expectedLatestVersionId: options.expectedLatestVersionId, + }); + createdVersionId = createdVersion.id; + } + + return createdVersionId ? { id: createdVersionId } : null; } /** @@ -126,7 +161,9 @@ export async function persistConfig(scopeCode: string, payload: unknown) { * @param scopeCode - The scope code to load configuration for. * @returns Promise resolving to parsed configuration or null if not found. */ -async function loadFromStateCache(scopeCode: string) { +async function loadFromStateCache( + scopeCode: string, +): Promise { const logger = getLogger( "@adobe/aio-commerce-lib-config:configuration-repository", ); @@ -150,7 +187,9 @@ async function loadFromStateCache(scopeCode: string) { * @param scopeCode - The scope code to load configuration for. * @returns Promise resolving to parsed configuration or null if not found. */ -async function loadFromPersistedFiles(scopeCode: string) { +async function loadFromPersistedFiles( + scopeCode: string, +): Promise { const logger = getLogger( "@adobe/aio-commerce-lib-config:configuration-repository", ); @@ -194,7 +233,9 @@ async function loadFromPersistedFiles(scopeCode: string) { * @param scopeCode - The scope code to load configuration for. * @returns Promise resolving to configuration payload or null if not found. */ -export async function loadConfig(scopeCode: string) { +export async function loadConfig( + scopeCode: string, +): Promise { const fromState = await loadFromStateCache(scopeCode); if (fromState) { return fromState; diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/get-config-by-key.ts b/packages/aio-commerce-lib-config/source/modules/configuration/get-config-by-key.ts index 8baa3bac0..d06216cfc 100644 --- a/packages/aio-commerce-lib-config/source/modules/configuration/get-config-by-key.ts +++ b/packages/aio-commerce-lib-config/source/modules/configuration/get-config-by-key.ts @@ -33,7 +33,7 @@ export async function getConfigurationByKey( context: ConfigContext, configKey: string, ...args: unknown[] -) { +): Promise { const fullConfig = await getConfiguration(context, ...args); const configValue = fullConfig.config.find((item) => item.name === configKey) || null; diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts b/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts index 37139d380..bf7a69a70 100644 --- a/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts +++ b/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts @@ -82,7 +82,10 @@ export async function setConfiguration( config: mergedScopeConfig, }; - await configRepository.persistConfig(scopeCode, payload); + await configRepository.persistConfig(scopeCode, payload, { + auditEnabled: context.auditEnabled, + reason: "set", + }); const responseConfig = sanitizedEntries.map((entry) => ({ name: entry.name, value: entry.value, diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/types.ts b/packages/aio-commerce-lib-config/source/modules/configuration/types.ts index 7a8764ad1..320a1645f 100644 --- a/packages/aio-commerce-lib-config/source/modules/configuration/types.ts +++ b/packages/aio-commerce-lib-config/source/modules/configuration/types.ts @@ -53,4 +53,6 @@ export type ConfigContext = { namespace: string; /** Cache timeout in milliseconds. */ cacheTimeout: number; + /** Whether audit/versioning behavior is enabled for this operation. */ + auditEnabled: boolean; }; diff --git a/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts b/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts new file mode 100644 index 000000000..847d822e2 --- /dev/null +++ b/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts @@ -0,0 +1,413 @@ +/* + * 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 { v7 as uuidv7 } from "uuid"; + +import { getSharedFiles } from "#utils/repository"; + +import type { ConfigurationVersion } from "#types/api"; + +const VERSION_FILE_EXTENSION_REGEX = /\.json$/u; + +export type CreateVersionRecordOptions = { + reason: "set" | "restore"; + restoredFromVersionId?: string; + expectedLatestVersionId?: string; +}; + +export type VersionRecord = ConfigurationVersion & { + snapshot: unknown; +}; + +export type VersionPage = { + versions: ConfigurationVersion[]; + total: number; + limit: number; + offset: number; +}; + +/** + * Creates and persists an immutable version record for the given scope. + */ +export async function createVersionRecord( + scopeCode: string, + payload: unknown, + options: CreateVersionRecordOptions, +): Promise { + const files = await getSharedFiles(); + const latest = await getLatestVersionRecord(scopeCode); + + if ( + options.expectedLatestVersionId !== undefined && + latest?.id !== options.expectedLatestVersionId + ) { + throw new Error("VERSION_CONFLICT: latest version does not match expected"); + } + + const change = computeVersionChange(latest?.snapshot, payload); + const normalizedScope = normalizeScope(payload, scopeCode); + + const record: VersionRecord = { + id: uuidv7(), + timestamp: new Date().toISOString(), + scope: normalizedScope, + reason: options.reason, + restoredFromVersionId: options.restoredFromVersionId, + change, + snapshot: payload, + }; + + await files.write( + getVersionSnapshotPath(scopeCode, record.id), + JSON.stringify(record), + ); + // Best-effort optimization only. Snapshot files are the source of truth. + await tryUpdateVersionIndex(scopeCode, record); + + return record; +} + +/** + * Lists version metadata for a scope with offset/limit paging. + */ +export async function listVersionRecords( + scopeCode: string, + params: { limit?: number; offset?: number } = {}, +): Promise { + const allVersionIds = await listVersionIds(scopeCode); + const limit = sanitizeLimit(params.limit); + const offset = sanitizeOffset(params.offset); + const selectedIds = allVersionIds.slice(offset, offset + limit); + const versionCandidates = await Promise.all( + selectedIds.map(async (versionId) => + readVersionSnapshot(scopeCode, versionId), + ), + ); + const versions = versionCandidates + .filter((version): version is VersionRecord => version !== null) + .map(toVersionMetadata); + + return { + versions, + total: allVersionIds.length, + limit, + offset, + }; +} + +/** + * Gets a specific version record (including snapshot) by id. + */ +export async function getVersionRecord( + scopeCode: string, + versionId: string, +): Promise { + return await readVersionSnapshot(scopeCode, versionId); +} + +async function readVersionSnapshot( + scopeCode: string, + versionId: string, +): Promise { + try { + const files = await getSharedFiles(); + const path = getVersionSnapshotPath(scopeCode, versionId); + const content = await files.read(path); + if (!content) { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content.toString("utf8")); + } catch { + throw new Error( + `CORRUPT_VERSION_RECORD: invalid JSON for scope='${scopeCode}' version='${versionId}'`, + ); + } + + if (!isVersionRecord(parsed)) { + throw new Error( + `CORRUPT_VERSION_RECORD: invalid shape for scope='${scopeCode}' version='${versionId}'`, + ); + } + + return parsed; + } catch (error) { + if (isNotFoundError(error)) { + return null; + } + + throw error; + } +} + +async function readVersionIndex( + scopeCode: string, +): Promise { + try { + const files = await getSharedFiles(); + const indexPath = getVersionIndexPath(scopeCode); + const versionDir = getVersionDirectoryPath(scopeCode); + const filesList = await files.list(versionDir); + const indexExists = filesList.some((file) => file.name === indexPath); + if (!indexExists) { + return []; + } + + const content = await files.read(indexPath); + if (!content) { + return []; + } + + const parsed = JSON.parse(content.toString("utf8")); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter(isVersionMetadata); + } catch (_) { + return []; + } +} + +async function getLatestVersionRecord( + scopeCode: string, +): Promise { + const [latestVersionId] = await listVersionIds(scopeCode); + if (!latestVersionId) { + return null; + } + return await readVersionSnapshot(scopeCode, latestVersionId); +} + +async function listVersionIds(scopeCode: string): Promise { + try { + const files = await getSharedFiles(); + const versionDir = getVersionDirectoryPath(scopeCode); + const filesList = await files.list(versionDir); + return filesList + .map((file) => file.name) + .filter((path) => isVersionSnapshotPath(path, scopeCode)) + .map((path) => getVersionIdFromPath(path)) + .sort((left, right) => right.localeCompare(left)); + } catch (_) { + return []; + } +} + +async function tryUpdateVersionIndex( + scopeCode: string, + record: VersionRecord, +): Promise { + try { + const files = await getSharedFiles(); + const index = await readVersionIndex(scopeCode); + const nextIndex = [toVersionMetadata(record), ...index].filter( + (version, indexPosition, allVersions) => + allVersions.findIndex((entry) => entry.id === version.id) === + indexPosition, + ); + await files.write( + getVersionIndexPath(scopeCode), + JSON.stringify(nextIndex), + ); + } catch (_) { + // Ignore index write failures, listing uses snapshots directly. + } +} + +function isVersionMetadata(value: unknown): value is ConfigurationVersion { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.timestamp === "string" && + typeof candidate.reason === "string" && + !!candidate.scope && + Array.isArray(candidate.change?.added) && + Array.isArray(candidate.change?.updated) && + Array.isArray(candidate.change?.removed) + ); +} + +function isVersionRecord(value: unknown): value is VersionRecord { + return ( + isVersionMetadata(value) && + typeof value === "object" && + value !== null && + "snapshot" in value + ); +} + +function normalizeScope( + payload: unknown, + scopeCode: string, +): { id: string; code: string; level: string } { + if ( + typeof payload === "object" && + payload !== null && + "scope" in payload && + typeof payload.scope === "object" && + payload.scope !== null && + "code" in payload.scope && + "level" in payload.scope && + "id" in payload.scope + ) { + const scope = payload.scope as { + id: unknown; + code: unknown; + level: unknown; + }; + if ( + typeof scope.id === "string" && + typeof scope.code === "string" && + typeof scope.level === "string" + ) { + return { id: scope.id, code: scope.code, level: scope.level }; + } + } + + return { id: scopeCode, code: scopeCode, level: "unknown" }; +} + +function toVersionMetadata(record: VersionRecord): ConfigurationVersion { + const { snapshot: _snapshot, ...metadata } = record; + return metadata; +} + +function sanitizeLimit(limit: number | undefined): number { + const defaultLimit = 50; + const maxLimit = 200; + if (typeof limit !== "number" || Number.isNaN(limit)) { + return defaultLimit; + } + + const rounded = Math.trunc(limit); + if (rounded < 1) { + return 1; + } + if (rounded > maxLimit) { + return maxLimit; + } + return rounded; +} + +function sanitizeOffset(offset: number | undefined): number { + if (typeof offset !== "number" || Number.isNaN(offset)) { + return 0; + } + + const rounded = Math.trunc(offset); + return rounded < 0 ? 0 : rounded; +} + +function getVersionDirectoryPath(scopeCode: string): string { + return `scope/${scopeCode.toLowerCase()}/versions/`; +} + +function getVersionIndexPath(scopeCode: string): string { + return `${getVersionDirectoryPath(scopeCode)}index.json`; +} + +function getVersionSnapshotPath(scopeCode: string, versionId: string): string { + return `${getVersionDirectoryPath(scopeCode)}${versionId}.json`; +} + +function isVersionSnapshotPath(path: string, scopeCode: string): boolean { + const prefix = getVersionDirectoryPath(scopeCode); + return ( + path.startsWith(prefix) && + path.endsWith(".json") && + !path.endsWith("index.json") + ); +} + +function getVersionIdFromPath(path: string): string { + const segments = path.split("/"); + const fileName = segments.at(-1) || ""; + return fileName.replace(VERSION_FILE_EXTENSION_REGEX, ""); +} + +function computeVersionChange( + previousPayload: unknown, + currentPayload: unknown, +): ConfigurationVersion["change"] { + const previous = getConfigNameValueMap(previousPayload); + const current = getConfigNameValueMap(currentPayload); + + const added: string[] = []; + const updated: string[] = []; + const removed: string[] = []; + + for (const [name, value] of current.entries()) { + if (!previous.has(name)) { + added.push(name); + continue; + } + + if (previous.get(name) !== value) { + updated.push(name); + } + } + + for (const name of previous.keys()) { + if (!current.has(name)) { + removed.push(name); + } + } + + return { added, updated, removed }; +} + +function getConfigNameValueMap(payload: unknown): Map { + const entries = new Map(); + if (!isPayloadWithConfig(payload)) { + return entries; + } + + for (const entry of payload.config) { + if ( + entry && + typeof entry === "object" && + "name" in entry && + "value" in entry && + typeof entry.name === "string" + ) { + entries.set(entry.name, JSON.stringify(entry.value)); + } + } + return entries; +} + +function isPayloadWithConfig( + payload: unknown, +): payload is { config: Array<{ name: string; value: unknown }> } { + return ( + typeof payload === "object" && + payload !== null && + "config" in payload && + Array.isArray(payload.config) + ); +} + +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + (("statusCode" in error && error.statusCode === 404) || + ("code" in error && error.code === "ENOENT")) + ); +} diff --git a/packages/aio-commerce-lib-config/source/types/api.ts b/packages/aio-commerce-lib-config/source/types/api.ts index 93a74ac0b..e792b55b6 100644 --- a/packages/aio-commerce-lib-config/source/types/api.ts +++ b/packages/aio-commerce-lib-config/source/types/api.ts @@ -88,6 +88,98 @@ export type SetConfigurationResponse = { }>; }; +/** + * Change summary between two configuration versions. + */ +export type ConfigurationVersionChange = { + /** Config keys that were added in this version. */ + added: string[]; + /** Config keys that were updated in this version. */ + updated: string[]; + /** Config keys that were removed in this version. */ + removed: string[]; +}; + +/** + * Configuration version metadata. + */ +export type ConfigurationVersion = { + /** Unique version identifier. */ + id: string; + /** ISO timestamp for when this version was created. */ + timestamp: string; + /** Scope information for this version. */ + scope: { + id: string; + code: string; + level: string; + }; + /** Why this version was created. */ + reason: "set" | "restore"; + /** Source version ID if created by restore. */ + restoredFromVersionId?: string; + /** Added/updated/removed key summary for this version. */ + change: ConfigurationVersionChange; +}; + +/** + * Request query params for listing configuration versions. + */ +export type GetConfigurationVersionsParams = { + /** Number of items to return. Defaults to 50. */ + limit?: number; + /** Number of items to skip. Defaults to 0. */ + offset?: number; +}; + +/** + * Response type for listing configuration versions. + */ +export type GetConfigurationVersionsResponse = { + /** Scope information including id, code, and level. */ + scope: { + id: string; + code: string; + level: string; + }; + /** Version metadata in descending order (newest first). */ + versions: ConfigurationVersion[]; + /** Pagination metadata for the current query. */ + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +/** + * Request body for restoring a configuration version. + */ +export type RestoreConfigurationVersionRequest = { + /** Version ID to restore from. */ + versionId: string; + /** Optional expected latest version ID for optimistic concurrency control. */ + expectedLatestVersionId?: string; +}; + +/** + * Response type for restoring a configuration version. + */ +export type RestoreConfigurationVersionResponse = { + /** Success message. */ + message: string; + /** ISO timestamp of when restore completed. */ + timestamp: string; + /** Scope information including id, code, and level. */ + scope: { + id: string; + code: string; + level: string; + }; + /** Version ID created by the restore operation. */ + restoredVersionId: string; +}; + /** * Request type for setting custom scope tree. */ diff --git a/packages/aio-commerce-lib-config/source/types/index.ts b/packages/aio-commerce-lib-config/source/types/index.ts index d166fe293..c3fb23494 100644 --- a/packages/aio-commerce-lib-config/source/types/index.ts +++ b/packages/aio-commerce-lib-config/source/types/index.ts @@ -19,6 +19,8 @@ export type LibConfigOptions = { cacheTimeout?: number; /** Optional encryption key for encrypting/decrypting password fields. If not provided, falls back to AIO_COMMERCE_CONFIG_ENCRYPTION_KEY environment variable. */ encryptionKey?: string | null; + /** Optional flag to enable config audit/versioning features. Defaults to true. */ + auditEnabled?: boolean; }; /** @@ -28,4 +30,5 @@ export type LibConfigOptions = { export type GlobalLibConfigOptions = { cacheTimeout: number; encryptionKey?: string | null; + auditEnabled: boolean; }; 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 3cea7befc..56293cad3 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,18 @@ 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 value = this.files.get(path); + if (value === undefined) { + const error = new Error( + `ENOENT: no such file or directory, open '${path}'`, + ); + // Keep parity with filesystem-style errors used in production checks. + (error as Error & { code: string }).code = "ENOENT"; + throw error; + } + return Buffer.from(value); + }); public write = vi.fn( async ( diff --git a/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts b/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts index 9aa888d67..503091908 100644 --- a/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts +++ b/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts @@ -14,10 +14,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getConfiguration, + getConfigurationVersions, + restoreConfigurationVersion, setConfiguration, unsyncCommerceScopes, } from "#config-manager"; -import { byCodeAndLevel, byScopeId } from "#config-utils"; +import { byCode, byCodeAndLevel, byScopeId } from "#config-utils"; import * as configRepository from "#modules/configuration/configuration-repository"; import * as scopeTreeRepository from "#modules/scope-tree/scope-tree-repository"; import { mockScopeTree } from "#test/fixtures/scope-tree"; @@ -258,6 +260,232 @@ describe("ConfigManager functions", () => { ).toBe("JPY"); }); + it("creates a version record by default when setting configuration", async () => { + await setConfiguration( + { config: [{ name: "currency", value: "USD" }] }, + byCodeAndLevel("global", "global"), + ); + + const result = await getConfigurationVersions( + byCodeAndLevel("global", "global"), + ); + + expect(result.versions).toHaveLength(1); + expect(result.versions[0].change.added).toEqual(["currency"]); + expect(result.versions[0].change.updated).toEqual([]); + expect(result.versions[0].change.removed).toEqual([]); + }); + + it("supports listing versions by code selector without explicit level", async () => { + await setConfiguration( + { config: [{ name: "currency", value: "USD" }] }, + byCodeAndLevel("global", "global"), + ); + + const result = await getConfigurationVersions(byCode("global")); + + expect(result.scope.code).toBe("global"); + expect(result.versions).toHaveLength(1); + }); + + it("fails when code selector is ambiguous across multiple levels", async () => { + const ambiguousScopeTree: ScopeTree = [ + { + id: "id-global", + code: "global", + label: "Global", + level: "global", + is_editable: false, + is_final: false, + is_removable: false, + children: [ + { + id: "id-dup-store", + code: "dup", + label: "Duplicate Store", + level: "store", + is_editable: true, + is_final: true, + is_removable: true, + }, + ], + }, + { + id: "id-dup-website", + code: "dup", + label: "Duplicate Website", + level: "website", + is_editable: true, + is_final: true, + is_removable: true, + }, + ]; + vi.mocked(scopeTreeRepository.getPersistedScopeTree).mockResolvedValue( + ambiguousScopeTree, + ); + + await expect(getConfiguration(byCode("dup"))).rejects.toThrow( + "AMBIGUOUS_SCOPE_CODE", + ); + }); + + it("skips version creation when audit feature is disabled", async () => { + await setConfiguration( + { config: [{ name: "currency", value: "USD" }] }, + byCodeAndLevel("global", "global"), + { auditEnabled: false }, + ); + + const config = await getConfiguration(byCodeAndLevel("global", "global")); + expect( + config.config.find((entry) => entry.name === "currency")?.value, + ).toBe("USD"); + + const versions = await getConfigurationVersions( + byCodeAndLevel("global", "global"), + ); + expect(versions.versions).toHaveLength(0); + + await expect( + getConfigurationVersions(byCodeAndLevel("global", "global"), undefined, { + auditEnabled: false, + }), + ).rejects.toThrow("AUDIT_DISABLED"); + }); + + it("restores a previous version and creates a new restore version entry", async () => { + await setConfiguration( + { config: [{ name: "currency", value: "USD" }] }, + byCodeAndLevel("global", "global"), + ); + await setConfiguration( + { config: [{ name: "currency", value: "EUR" }] }, + byCodeAndLevel("global", "global"), + ); + + const history = await getConfigurationVersions( + byCodeAndLevel("global", "global"), + ); + const targetVersion = history.versions.find((version) => + version.change.added.includes("currency"), + ); + + expect.assert(targetVersion, "targetVersion should exist"); + + await restoreConfigurationVersion(byCodeAndLevel("global", "global"), { + versionId: targetVersion.id, + }); + + const restored = await getConfiguration(byCodeAndLevel("global", "global")); + expect( + restored.config.find((entry) => entry.name === "currency")?.value, + ).toBe("USD"); + + const afterRestoreHistory = await getConfigurationVersions( + byCodeAndLevel("global", "global"), + ); + expect(afterRestoreHistory.versions).toHaveLength(3); + }); + + it("throws when restoring an unknown version", async () => { + await setConfiguration( + { config: [{ name: "currency", value: "USD" }] }, + byCodeAndLevel("global", "global"), + ); + + await expect( + restoreConfigurationVersion(byCodeAndLevel("global", "global"), { + versionId: "missing-version-id", + }), + ).rejects.toThrow("VERSION_NOT_FOUND"); + }); + + it("fails restore when stored version snapshot has invalid shape", async () => { + await setConfiguration( + { config: [{ name: "currency", value: "USD" }] }, + byCodeAndLevel("global", "global"), + ); + const history = await getConfigurationVersions( + byCodeAndLevel("global", "global"), + ); + const [version] = history.versions; + expect.assert(version, "version should exist"); + + const malformedRecord = { + id: version.id, + timestamp: version.timestamp, + scope: version.scope, + reason: version.reason, + change: version.change, + snapshot: { invalid: true }, + }; + await mockFilesInstance.write( + `scope/global/versions/${version.id}.json`, + JSON.stringify(malformedRecord), + ); + + await expect( + restoreConfigurationVersion(byCodeAndLevel("global", "global"), { + versionId: version.id, + }), + ).rejects.toThrow("VERSION_SNAPSHOT_INVALID"); + }); + + it("persists config even when version write fails", async () => { + const originalWriteImpl = mockFilesInstance.write.getMockImplementation(); + mockFilesInstance.write.mockImplementation(async (path, content) => { + if (path.includes("/versions/")) { + throw new Error("version write failed"); + } + + if (!originalWriteImpl) { + throw new Error("original write implementation missing"); + } + return await originalWriteImpl(path, content); + }); + + await expect( + setConfiguration( + { config: [{ name: "currency", value: "USD" }] }, + byCodeAndLevel("global", "global"), + ), + ).rejects.toThrow("version write failed"); + + const persisted = await configRepository.getPersistedConfig("global"); + expect.assert(persisted, "persisted config should remain written"); + const parsed = JSON.parse(persisted); + expect( + parsed.config.find((entry: ConfigValue) => entry.name === "currency"), + ).toBeDefined(); + }); + + it("still records versions when version index writes fail", async () => { + const originalWriteImpl = mockFilesInstance.write.getMockImplementation(); + mockFilesInstance.write.mockImplementation(async (path, content) => { + if (path.endsWith("/versions/index.json")) { + throw new Error("index write failed"); + } + if (!originalWriteImpl) { + throw new Error("original write implementation missing"); + } + return await originalWriteImpl(path, content); + }); + + await setConfiguration( + { config: [{ name: "currency", value: "USD" }] }, + byCodeAndLevel("global", "global"), + ); + await setConfiguration( + { config: [{ name: "currency", value: "EUR" }] }, + byCodeAndLevel("global", "global"), + ); + + const result = await getConfigurationVersions( + byCodeAndLevel("global", "global"), + ); + expect(result.versions).toHaveLength(2); + }); + it("merges existing and newly set entries without losing prior values", async () => { // Set initial config await configRepository.saveConfig( From 83c5caac3ba1e9eba7c05a418b86cf44154f463a Mon Sep 17 00:00:00 2001 From: Lars Roettig Date: Wed, 4 Mar 2026 15:42:40 +0100 Subject: [PATCH 2/6] Audit --- .../plugins/selective-bundle/helpers.js | 107 +----------------- 1 file changed, 2 insertions(+), 105 deletions(-) diff --git a/configs/tsdown/plugins/selective-bundle/helpers.js b/configs/tsdown/plugins/selective-bundle/helpers.js index cdaeec45c..58f327ce8 100644 --- a/configs/tsdown/plugins/selective-bundle/helpers.js +++ b/configs/tsdown/plugins/selective-bundle/helpers.js @@ -17,100 +17,10 @@ import { realpathSync, writeFileSync, } from "node:fs"; -import { dirname, resolve, sep } from "node:path"; +import { resolve, sep } from "node:path"; import { PRIVATE_DEPS_LOOKUP } from "./constants.js"; -const CATALOG_HEADER_REGEX = /^catalog:\s*$/; -const LEADING_WHITESPACE_REGEX = /^\s/; -const COMMENT_LINE_REGEX = /^\s*#/; -const CATALOG_ENTRY_REGEX = /^\s+(.+?)\s*:\s*(.+)$/; - -/** - * Walks up the directory tree from `startDir` to find `pnpm-workspace.yaml`. - * @param {string} startDir - * @returns {string | null} - */ -function findWorkspaceYaml(startDir) { - let dir = startDir; - while (true) { - const candidate = resolve(dir, "pnpm-workspace.yaml"); - if (existsSync(candidate)) { - return candidate; - } - const parent = dirname(dir); - if (parent === dir) { - return null; - } - dir = parent; - } -} - -/** - * Parses the default `catalog:` block from a pnpm-workspace.yaml file. - * Does not require an external YAML library — only handles the simple - * flat key-value catalog structure used in this repo. - * - * @param {string} yamlPath - * @returns {Record} - */ -function loadPnpmCatalog(yamlPath) { - const catalog = {}; - const lines = readFileSync(yamlPath, "utf-8").split("\n"); - let inCatalog = false; - - for (const line of lines) { - if (CATALOG_HEADER_REGEX.test(line)) { - inCatalog = true; - continue; - } - - if (inCatalog) { - // A non-empty, non-comment line without leading whitespace ends the block - if ( - line.length > 0 && - !LEADING_WHITESPACE_REGEX.test(line) && - !COMMENT_LINE_REGEX.test(line) - ) { - inCatalog = false; - continue; - } - - // Parse " 'key': value" or " key: value" - const match = CATALOG_ENTRY_REGEX.exec(line.trimEnd()); - if (match) { - const name = match[1].replace(/^['"]|['"]$/g, "").trim(); - catalog[name] = match[2].trim(); - } - } - } - - return catalog; -} - -/** - * Resolves `catalog:` protocol references in a deps object to their - * actual semver ranges from the workspace catalog. - * - * @param {Record | undefined} deps - * @param {Record} catalog - * @returns {Record | undefined} - */ -function resolveCatalogRefs(deps, catalog) { - if (!deps) { - return deps; - } - - return Object.fromEntries( - Object.entries(deps).map(([name, version]) => { - if (version === "catalog:" || version.startsWith("catalog:")) { - return [name, catalog[name] ?? version]; - } - return [name, version]; - }), - ); -} - /** * Gets the bare module name from an import source string. * @param {string} source @@ -200,23 +110,10 @@ export function buildEnrichedPackageJson(packageRoot, manifest) { readFileSync(resolve(packageRoot, "package.json"), "utf-8"), ); - // Resolve catalog: protocol references so the packed tarball contains - // plain semver ranges that npm/yarn/bun can understand. - const workspaceYamlPath = findWorkspaceYaml(packageRoot); - const catalog = workspaceYamlPath ? loadPnpmCatalog(workspaceYamlPath) : {}; - pkg.dependencies = resolveCatalogRefs(pkg.dependencies, catalog); - pkg.peerDependencies = resolveCatalogRefs(pkg.peerDependencies, catalog); - pkg.devDependencies = resolveCatalogRefs(pkg.devDependencies, catalog); - pkg.dependencies ??= {}; for (const [name, info] of Object.entries(manifest)) { - // Resolve catalog: in transitive deps from private packages before merging - const version = - info.version === "catalog:" || info.version.startsWith("catalog:") - ? (catalog[name] ?? info.version) - : info.version; - pkg.dependencies[name] ??= version; + pkg.dependencies[name] ??= info.version; } pkg.dependencies = Object.fromEntries( From 239914e220222b5b253227a850406d20fe3fdd6a Mon Sep 17 00:00:00 2001 From: Lars Roettig Date: Wed, 4 Mar 2026 16:30:01 +0100 Subject: [PATCH 3/6] Resolve merge conflict and merge main --- .../get-configuration-versions.js.template | 2 +- .../restore-configuration-version.js.template | 6 ++- .../set-configuration.js.template | 42 +++++++++++++------ .../source/config-manager.ts | 21 ++++------ .../source/config-utils.ts | 33 +++++++++------ .../configuration/configuration-repository.ts | 10 ++++- .../modules/versioning/version-repository.ts | 3 +- 7 files changed, 76 insertions(+), 41 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template index 5e103d5bc..d636531e8 100644 --- a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template +++ b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template @@ -76,7 +76,7 @@ export async function main(params) { return badRequest({ body: { code: "INVALID_PARAMS", - message: "limit and offset must be positive integers", + message: "limit and offset must be non-negative integers", }, }); } diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template index 1f20c3cd0..6669158b0 100644 --- a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template +++ b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template @@ -57,7 +57,11 @@ export async function main(params) { const code = params.code; const level = params.level; const versionId = params.versionId; - const expectedLatestVersionId = params.expectedLatestVersionId; + const expectedLatestVersionId = + typeof params.expectedLatestVersionId === "string" && + params.expectedLatestVersionId.trim().length > 0 + ? params.expectedLatestVersionId + : undefined; if (!(id || (code && level))) { return badRequest({ diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template index 0f662dec4..db7cbd15b 100644 --- a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template +++ b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template @@ -34,8 +34,26 @@ import AioLogger from "@adobe/aio-lib-core-logging"; // Shorthand to inspect an object. const inspect = (obj) => util.inspect(obj, { depth: null }); +function parseBodyFromParams(params) { + if (!params || typeof params !== "object") { + return {}; + } + + const rawBody = params.__ow_body; + if (typeof rawBody === "string" && rawBody.trim().length > 0) { + try { + const parsed = JSON.parse(rawBody); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } + } + + return rawBody && typeof rawBody === "object" ? rawBody : {}; +} + /** - * Get the configuration. + * Set configuration values and return the updated configuration. * @param params - The input parameters. * @returns The response object containing the updated configuration values. */ @@ -53,15 +71,7 @@ export async function main(params) { setGlobalLibConfigOptions({ auditEnabled: parseAuditEnabled(params.AIO_COMMERCE_CONFIG_AUDIT_ENABLED), }); - let body; - - if ( - params && - typeof params === "object" && - Object.keys(params).length === 0 - ) { - body = {}; - } + const body = parseBodyFromParams(params); const id = params.id; const code = params.code; @@ -100,13 +110,21 @@ export async function main(params) { } const payload = { config: candidateConfig }; - logger.debug(`Setting configuration with payload: ${inspect(payload)}`); + logger.debug( + `Setting configuration for scope: ${ + id ? `id=${id}` : `code=${code}, level=${level}` + }; entries=${candidateConfig.length}`, + ); const result = id ? await setConfiguration(payload, byScopeId(id)) : await setConfiguration(payload, byCodeAndLevel(code, level)); - logger.debug(`Successfully set configuration: ${inspect(result)}`); + logger.debug( + `Successfully set configuration for scope: ${ + id ? `id=${id}` : `code=${code}, level=${level}` + }`, + ); return ok({ body: { result }, headers: { diff --git a/packages/aio-commerce-lib-config/source/config-manager.ts b/packages/aio-commerce-lib-config/source/config-manager.ts index 8d88f4ea4..c4b1cebda 100644 --- a/packages/aio-commerce-lib-config/source/config-manager.ts +++ b/packages/aio-commerce-lib-config/source/config-manager.ts @@ -129,8 +129,8 @@ export function setGlobalLibConfigOptions(options: LibConfigOptions) { } /** - * Gets the global encryption key. - * @returns The encryption key or undefined if not set. + * Gets global library configuration defaults. + * @returns The current global library configuration options. * @internal */ export function getGlobalLibConfigOptions(): GlobalLibConfigOptions { @@ -386,21 +386,18 @@ export async function syncCommerceScopes( /** * Removes the commerce scope from the persisted scope tree. * - * @returns Promise resolving to a boolean indicating whether the scope was found and removed, - * or if it was already not present. + * @returns Promise resolving to an object with `unsynced` indicating whether the scope + * was found and removed. * * @example * ```typescript * import { unsyncCommerceScopes } from "@adobe/aio-commerce-lib-config"; * - * try { - * const result = await unsyncCommerceScopes(); - * - * if (result) { - * console.log("Commerce scope removed successfully"); - * } - * } catch (error) { - * console.error("Failed to unsync commerce scopes:", error); + * const result = await unsyncCommerceScopes(); + * if (result.unsynced) { + * console.log("Commerce scope removed successfully"); + * } else { + * console.log("Commerce scope not found"); * } * ``` */ diff --git a/packages/aio-commerce-lib-config/source/config-utils.ts b/packages/aio-commerce-lib-config/source/config-utils.ts index adfb0ed1b..af92d59cd 100644 --- a/packages/aio-commerce-lib-config/source/config-utils.ts +++ b/packages/aio-commerce-lib-config/source/config-utils.ts @@ -178,7 +178,7 @@ export function deriveScopeFromCodeWithOptionalLevel( /** * Derives the scope information from the provided arguments. - * @param args - The arguments containing either (id), (code), or (code, level). + * @param args - The arguments containing either (id) or (code, level). * @param tree - The scope tree to search for the node. * @returns The derived scope information including code, level, id, and path. */ @@ -348,9 +348,10 @@ function mergeConfigEntries( async function mergeConfigFromPath( merged: Map, scopePath: ScopeNode[], - loadScopeConfigFn: ( - code: string, - ) => Promise<{ scope: ScopeNode; config: ConfigValue[] } | null>, + loadScopeConfigFn: (code: string) => Promise<{ + scope: { id: string; code: string; level: string }; + config: ConfigValue[]; + } | null>, ) { for (const node of scopePath) { const persisted = await loadScopeConfigFn(node.code); @@ -372,9 +373,10 @@ async function mergeConfigFromPath( async function mergeGlobalConfigIfNeeded( merged: Map, scopePath: ScopeNode[], - loadScopeConfigFn: ( - code: string, - ) => Promise<{ scope: ScopeNode; config: ConfigValue[] } | null>, + loadScopeConfigFn: (code: string) => Promise<{ + scope: { id: string; code: string; level: string }; + config: ConfigValue[]; + } | null>, ) { const hasGlobal = scopePath.some( (node) => node.code === "global" && node.level === "global", @@ -400,7 +402,10 @@ async function mergeGlobalConfigIfNeeded( */ function mergeCurrentConfigData( merged: Map, - configData: { scope: ScopeNode; config: ConfigValue[] }, + configData: { + scope: { id: string; code: string; level: string }; + config: ConfigValue[]; + }, scopeCode: string, scopeLevel: string, ) { @@ -442,11 +447,15 @@ function applySchemaDefaults( /** Parameters for mergeWithSchemaDefaults function */ type MergeWithSchemaDefaultsParams = { - loadScopeConfigFn: ( - code: string, - ) => Promise<{ scope: ScopeNode; config: ConfigValue[] } | null>; + loadScopeConfigFn: (code: string) => Promise<{ + scope: { id: string; code: string; level: string }; + config: ConfigValue[]; + } | null>; getSchemaFn: () => Promise; - configData: { scope: ScopeNode; config: ConfigValue[] }; + configData: { + scope: { id: string; code: string; level: string }; + config: ConfigValue[]; + }; scopeCode: string; scopeLevel: string; scopePath: ScopeNode[]; 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 6de7c0dea..bcea4362c 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 @@ -16,6 +16,8 @@ import { createVersionRecord } from "#modules/versioning/version-repository"; import { getLogger } from "#utils/logger"; import { getSharedFiles, getSharedState } from "#utils/repository"; +import type { ConfigValue } from "./types"; + type PersistConfigOptions = { auditEnabled?: boolean; reason?: "set" | "restore"; @@ -24,8 +26,12 @@ type PersistConfigOptions = { }; type PersistedConfigPayload = { - scope?: unknown; - config?: unknown; + scope: { + id: string; + code: string; + level: string; + }; + config: ConfigValue[]; }; /** diff --git a/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts b/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts index 847d822e2..830365f0d 100644 --- a/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts +++ b/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts @@ -386,7 +386,8 @@ function getConfigNameValueMap(payload: unknown): Map { "value" in entry && typeof entry.name === "string" ) { - entries.set(entry.name, JSON.stringify(entry.value)); + const serializedValue = JSON.stringify(entry.value); + entries.set(entry.name, serializedValue ?? "null"); } } return entries; From b5546e37fb5baee3a967e81c0d8b0437c7ef073e Mon Sep 17 00:00:00 2001 From: Lars Roettig Date: Wed, 4 Mar 2026 22:49:14 +0100 Subject: [PATCH 4/6] refactor(app-config): migrate audit endpoints to REST config routes --- .../source/actions/config.ts | 245 +++++++++++++++++- .../commands/generate/actions/config.ts | 15 -- .../source/commands/generate/actions/main.ts | 12 - .../audit-enabled.js.template | 27 -- .../get-configuration-versions.js.template | 128 --------- .../restore-configuration-version.js.template | 123 --------- .../set-configuration.js.template | 147 ----------- .../test/unit/actions/config.test.ts | 178 +++++++++++++ .../commands/generate/actions/config.test.ts | 25 +- 9 files changed, 432 insertions(+), 468 deletions(-) delete mode 100644 packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/audit-enabled.js.template delete mode 100644 packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template delete mode 100644 packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template delete mode 100644 packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template create mode 100644 packages/aio-commerce-lib-app/test/unit/actions/config.test.ts diff --git a/packages/aio-commerce-lib-app/source/actions/config.ts b/packages/aio-commerce-lib-app/source/actions/config.ts index 1db58b574..3a0060a9c 100644 --- a/packages/aio-commerce-lib-app/source/actions/config.ts +++ b/packages/aio-commerce-lib-app/source/actions/config.ts @@ -11,12 +11,16 @@ */ import { + byCode, + byCodeAndLevel, byScopeId, getConfiguration, + getConfigurationVersions, initialize, + restoreConfigurationVersion, setConfiguration, } from "@adobe/aio-commerce-lib-config"; -import { ok } from "@adobe/aio-commerce-lib-core/responses"; +import { badRequest, ok } from "@adobe/aio-commerce-lib-core/responses"; import { HttpActionRouter, logger, @@ -42,6 +46,7 @@ type ConfigActionFactoryArgs = { type ConfigActionParams = RuntimeActionParams & ConfigActionFactoryArgs & { AIO_COMMERCE_CONFIG_ENCRYPTION_KEY?: string; + AIO_COMMERCE_CONFIG_AUDIT_ENABLED?: string | boolean; }; /** The context for the config action. */ @@ -52,6 +57,116 @@ interface ConfigActionContext extends BaseContext { // Placeholder value for password fields. const MASKED_PASSWORD_VALUE = "*****"; +function parseAuditEnabled(value: string | boolean | undefined): boolean { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } + } + return true; +} + +function parseNonNegativeInteger( + value: string | number | undefined, + name: "limit" | "offset", +): number | undefined { + if (value === undefined) { + return undefined; + } + + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`INVALID_PARAMS: ${name} must be a non-negative integer`); + } + return parsed; +} + +function resolveScopeSelector( + input: { + scopeId?: string; + id?: string; + code?: string; + level?: string; + }, + allowCodeOnly: boolean, +) { + const scopeId = input.scopeId ?? input.id; + if (scopeId) { + return byScopeId(scopeId); + } + if (input.code && input.level) { + return byCodeAndLevel(input.code, input.level); + } + if (allowCodeOnly && input.code) { + return byCode(input.code); + } + return null; +} + +type ScopeSelector = NonNullable>; +type BadRequestResponse = ReturnType; + +function auditDisabledResponse() { + return badRequest({ + body: { + code: "AUDIT_DISABLED", + message: "Audit feature is disabled", + }, + }); +} + +function resolveAuditOrBadRequest(rawParams: ConfigActionParams) { + const auditEnabled = parseAuditEnabled( + rawParams.AIO_COMMERCE_CONFIG_AUDIT_ENABLED, + ); + if (!auditEnabled) { + return { + ok: false, + response: auditDisabledResponse(), + } satisfies { ok: false; response: BadRequestResponse }; + } + return { + ok: true, + auditEnabled, + } satisfies { ok: true; auditEnabled: boolean }; +} + +function resolveSelectorOrBadRequest( + input: { + scopeId?: string; + id?: string; + code?: string; + level?: string; + }, + allowCodeOnly: boolean, + invalidSelectorMessage: string, +) { + const selector = resolveScopeSelector(input, allowCodeOnly); + if (!selector) { + return { + ok: false, + response: badRequest({ + body: { + code: "INVALID_PARAMS", + message: invalidSelectorMessage, + }, + }), + } satisfies { ok: false; response: BadRequestResponse }; + } + + return { + ok: true, + selector, + } satisfies { ok: true; selector: ScopeSelector }; +} + /** * Filters password fields from the configuration values. * @param schema - The schema to use to filter the values. @@ -126,6 +241,9 @@ router.post("/", { handler: async (req, ctx) => { const { logger, rawParams } = ctx; const { configSchema } = rawParams; + const auditEnabled = parseAuditEnabled( + rawParams.AIO_COMMERCE_CONFIG_AUDIT_ENABLED, + ); logger.debug(`Setting configuration with scope id: ${req.body.scopeId}`); const { scopeId, config } = req.body; @@ -140,6 +258,7 @@ router.post("/", { byScopeId(scopeId), { encryptionKey: rawParams.AIO_COMMERCE_CONFIG_ENCRYPTION_KEY, + auditEnabled, }, ); @@ -151,6 +270,130 @@ router.post("/", { }, }); +/** GET /versions - List configuration versions */ +router.get("/versions", { + query: v.object({ + scopeId: v.optional(nonEmptyStringValueSchema("scopeId")), + id: v.optional(nonEmptyStringValueSchema("id")), + code: v.optional(nonEmptyStringValueSchema("code")), + level: v.optional(nonEmptyStringValueSchema("level")), + limit: v.optional(v.union([v.string(), v.number()])), + offset: v.optional(v.union([v.string(), v.number()])), + }), + + handler: async (req, ctx) => { + const { rawParams } = ctx; + const auditResult = resolveAuditOrBadRequest(rawParams); + if (!auditResult.ok) { + return auditResult.response; + } + + const selectorResult = resolveSelectorOrBadRequest( + req.query, + true, + "Either scopeId/id or code query param is required", + ); + if (!selectorResult.ok) { + return selectorResult.response; + } + + const { auditEnabled } = auditResult; + const { selector } = selectorResult; + + try { + const limit = parseNonNegativeInteger(req.query.limit, "limit"); + const offset = parseNonNegativeInteger(req.query.offset, "offset"); + const result = await getConfigurationVersions( + selector, + { limit, offset }, + { auditEnabled }, + ); + + return ok({ + body: result, + headers: { "Cache-Control": "no-store" }, + }); + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("INVALID_PARAMS:") + ) { + return badRequest({ + body: { + code: "INVALID_PARAMS", + message: "limit and offset must be non-negative integers", + }, + }); + } + throw error; + } + }, +}); + +/** POST /versions/restore - Restore a configuration version */ +router.post("/versions/restore", { + body: v.object({ + scopeId: v.optional(nonEmptyStringValueSchema("scopeId")), + id: v.optional(nonEmptyStringValueSchema("id")), + code: v.optional(nonEmptyStringValueSchema("code")), + level: v.optional(nonEmptyStringValueSchema("level")), + versionId: nonEmptyStringValueSchema("versionId"), + expectedLatestVersionId: v.optional( + nonEmptyStringValueSchema("expectedLatestVersionId"), + ), + }), + + handler: async (req, ctx) => { + const { rawParams } = ctx; + const auditResult = resolveAuditOrBadRequest(rawParams); + if (!auditResult.ok) { + return auditResult.response; + } + + const selectorResult = resolveSelectorOrBadRequest( + req.body, + false, + "Either scopeId/id or both code and level are required for restore", + ); + if (!selectorResult.ok) { + return selectorResult.response; + } + + const { auditEnabled } = auditResult; + const { selector } = selectorResult; + + try { + const result = await restoreConfigurationVersion( + selector, + { + versionId: req.body.versionId, + expectedLatestVersionId: req.body.expectedLatestVersionId, + }, + { auditEnabled }, + ); + + return ok({ + body: result, + headers: { "Cache-Control": "no-store" }, + }); + } catch (error) { + if ( + error instanceof Error && + (error.message.includes("VERSION_NOT_FOUND") || + error.message.includes("VERSION_CONFLICT")) + ) { + return badRequest({ + body: { + code: "INVALID_REQUEST", + message: error.message, + }, + }); + } + throw error; + } + }, +}); + /** Factory to create the route handler for the `config` action. */ export const configRuntimeAction = ({ configSchema }: ConfigActionFactoryArgs) => 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 98df6859d..0a3407c87 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 @@ -187,21 +187,6 @@ export function buildBusinessConfigurationExtConfig(): ExtConfig { name: "config", templateFile: "config.js.template", requiresEncryptionKey: true, - }, - { - name: "set-configuration", - templateFile: "set-configuration.js.template", - requiresEncryptionKey: true, - requiresAuditFlag: true, - }, - { - name: "get-configuration-versions", - templateFile: "get-configuration-versions.js.template", - requiresAuditFlag: true, - }, - { - name: "restore-configuration-version", - templateFile: "restore-configuration-version.js.template", requiresAuditFlag: true, }, { diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/main.ts b/packages/aio-commerce-lib-app/source/commands/generate/actions/main.ts index efa219442..7504cccb6 100644 --- a/packages/aio-commerce-lib-app/source/commands/generate/actions/main.ts +++ b/packages/aio-commerce-lib-app/source/commands/generate/actions/main.ts @@ -152,18 +152,6 @@ async function generateActionFiles( const outputFiles: string[] = []; const templatesDir = join(__dirname, "generate/actions/templates"); - if (extensionPointId === CONFIGURATION_EXTENSION_POINT_ID) { - const sharedTemplatePath = join( - templatesDir, - "business-configuration", - "audit-enabled.js.template", - ); - const sharedOutputPath = join(outputDir, "audit-enabled.js"); - const sharedTemplate = await readFile(sharedTemplatePath, "utf-8"); - await writeFile(sharedOutputPath, sharedTemplate, "utf-8"); - outputFiles.push(` ${relative(process.cwd(), sharedOutputPath)}`); - } - for (const action of actions) { const templatePath = join(templatesDir, action.templateFile); let template = await readFile(templatePath, "utf-8"); diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/audit-enabled.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/audit-enabled.js.template deleted file mode 100644 index a87450c15..000000000 --- a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/audit-enabled.js.template +++ /dev/null @@ -1,27 +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. - */ - -export function parseAuditEnabled(value) { - if (typeof value === "boolean") { - return value; - } - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if (normalized === "true") { - return true; - } - if (normalized === "false") { - return false; - } - } - return true; -} diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template deleted file mode 100644 index d636531e8..000000000 --- a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template +++ /dev/null @@ -1,128 +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. - */ - -// This file has been auto-generated by `@adobe/aio-commerce-lib-config` -// Do not modify this file directly - -import util from "node:util"; - -import { - byCode, - byCodeAndLevel, - byScopeId, - getConfigurationVersions, - setGlobalLibConfigOptions, -} from "@adobe/aio-commerce-lib-config"; -import { parseAuditEnabled } from "./audit-enabled.js"; -import { - badRequest, - internalServerError, - ok, -} from "@adobe/aio-commerce-sdk/core/responses"; -import AioLogger from "@adobe/aio-lib-core-logging"; - -const inspect = (obj) => util.inspect(obj, { depth: null }); - -function parsePositiveInteger(value) { - if (value === undefined || value === null || value === "") { - return undefined; - } - - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 0) { - return null; - } - return parsed; -} - -/** - * Get configuration version history. - * @param params - The input parameters. - * @returns The response object containing version history. - */ -export async function main(params) { - const logger = AioLogger("get-configuration-versions", { - level: params.LOG_LEVEL || "info", - }); - - try { - const auditEnabled = parseAuditEnabled(params.AIO_COMMERCE_CONFIG_AUDIT_ENABLED); - setGlobalLibConfigOptions({ auditEnabled }); - if (!auditEnabled) { - return badRequest({ - body: { - code: "AUDIT_DISABLED", - message: "Audit feature is disabled", - }, - }); - } - - const id = params.id; - const code = params.code; - const level = params.level; - const limit = parsePositiveInteger(params.limit); - const offset = parsePositiveInteger(params.offset); - - if (limit === null || offset === null) { - return badRequest({ - body: { - code: "INVALID_PARAMS", - message: "limit and offset must be non-negative integers", - }, - }); - } - - if (!(id || code)) { - return badRequest({ - body: { - code: "INVALID_PARAMS", - message: "Either id or code query param is required", - }, - }); - } - - const selector = id - ? byScopeId(id) - : level - ? byCodeAndLevel(code, level) - : byCode(code); - const result = await getConfigurationVersions(selector, { limit, offset }); - - return ok({ - body: result, - headers: { - "Cache-Control": "no-store", - }, - }); - } catch (error) { - logger.error( - `Something went wrong while retrieving configuration versions: ${inspect(error)}`, - ); - - if (error instanceof Error && error.message.includes("AUDIT_DISABLED")) { - return badRequest({ - body: { - code: "AUDIT_DISABLED", - message: "Audit feature is disabled", - }, - }); - } - - return internalServerError({ - body: { - code: "INTERNAL_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - message: "An internal server error occurred", - }, - }); - } -} diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template deleted file mode 100644 index 6669158b0..000000000 --- a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template +++ /dev/null @@ -1,123 +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. - */ - -// This file has been auto-generated by `@adobe/aio-commerce-lib-config` -// Do not modify this file directly - -import util from "node:util"; - -import { - byCodeAndLevel, - byScopeId, - restoreConfigurationVersion, - setGlobalLibConfigOptions, -} from "@adobe/aio-commerce-lib-config"; -import { parseAuditEnabled } from "./audit-enabled.js"; -import { - badRequest, - internalServerError, - ok, -} from "@adobe/aio-commerce-sdk/core/responses"; -import AioLogger from "@adobe/aio-lib-core-logging"; - -const inspect = (obj) => util.inspect(obj, { depth: null }); - -/** - * Restore configuration from a version. - * @param params - The input parameters. - * @returns The response object containing restore details. - */ -export async function main(params) { - const logger = AioLogger("restore-configuration-version", { - level: params.LOG_LEVEL || "info", - }); - - try { - const auditEnabled = parseAuditEnabled(params.AIO_COMMERCE_CONFIG_AUDIT_ENABLED); - setGlobalLibConfigOptions({ auditEnabled }); - if (!auditEnabled) { - return badRequest({ - body: { - code: "AUDIT_DISABLED", - message: "Audit feature is disabled", - }, - }); - } - - const id = params.id; - const code = params.code; - const level = params.level; - const versionId = params.versionId; - const expectedLatestVersionId = - typeof params.expectedLatestVersionId === "string" && - params.expectedLatestVersionId.trim().length > 0 - ? params.expectedLatestVersionId - : undefined; - - if (!(id || (code && level))) { - return badRequest({ - body: { - code: "INVALID_PARAMS", - message: "Either id or both code and level query params are required", - }, - }); - } - - if (!(typeof versionId === "string" && versionId.trim().length > 0)) { - return badRequest({ - body: { - code: "INVALID_BODY", - message: "versionId is required", - }, - }); - } - - const selector = id ? byScopeId(id) : byCodeAndLevel(code, level); - const result = await restoreConfigurationVersion(selector, { - versionId, - expectedLatestVersionId, - }); - - return ok({ - body: result, - headers: { - "Cache-Control": "no-store", - }, - }); - } catch (error) { - logger.error( - `Something went wrong while restoring configuration version: ${inspect(error)}`, - ); - - if ( - error instanceof Error && - (error.message.includes("AUDIT_DISABLED") || - error.message.includes("VERSION_NOT_FOUND") || - error.message.includes("VERSION_CONFLICT")) - ) { - return badRequest({ - body: { - code: "INVALID_REQUEST", - message: error.message, - }, - }); - } - - return internalServerError({ - body: { - code: "INTERNAL_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - message: "An internal server error occurred", - }, - }); - } -} diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template deleted file mode 100644 index db7cbd15b..000000000 --- a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template +++ /dev/null @@ -1,147 +0,0 @@ -// @ts-check - -/* - * Copyright 2025 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-config` -// Do not modify this file directly - -import util from "node:util"; - -import { - byCodeAndLevel, - byScopeId, - setConfiguration, - setGlobalLibConfigOptions, -} from "@adobe/aio-commerce-lib-config"; -import { parseAuditEnabled } from "./audit-enabled.js"; -import { - badRequest, - internalServerError, - ok, -} from "@adobe/aio-commerce-sdk/core/responses"; -import AioLogger from "@adobe/aio-lib-core-logging"; - -// Shorthand to inspect an object. -const inspect = (obj) => util.inspect(obj, { depth: null }); - -function parseBodyFromParams(params) { - if (!params || typeof params !== "object") { - return {}; - } - - const rawBody = params.__ow_body; - if (typeof rawBody === "string" && rawBody.trim().length > 0) { - try { - const parsed = JSON.parse(rawBody); - return parsed && typeof parsed === "object" ? parsed : {}; - } catch { - return {}; - } - } - - return rawBody && typeof rawBody === "object" ? rawBody : {}; -} - -/** - * Set configuration values and return the updated configuration. - * @param params - The input parameters. - * @returns The response object containing the updated configuration values. - */ -export async function main(params) { - const logger = AioLogger("set-configuration", { - level: params.LOG_LEVEL || "info", - }); - - try { - if (params.AIO_COMMERCE_CONFIG_ENCRYPTION_KEY) { - setGlobalLibConfigOptions({ - encryptionKey: params.AIO_COMMERCE_CONFIG_ENCRYPTION_KEY, - }); - } - setGlobalLibConfigOptions({ - auditEnabled: parseAuditEnabled(params.AIO_COMMERCE_CONFIG_AUDIT_ENABLED), - }); - const body = parseBodyFromParams(params); - - const id = params.id; - const code = params.code; - const level = params.level; - - logger.debug( - `Setting configuration with params: ${inspect({ id, code, level })}`, - ); - - if (!(id || (code && level))) { - logger.warn( - "Invalid params: Either id or both code and level query params are required", - ); - - return badRequest({ - body: { - code: "INVALID_PARAMS", - message: "Either id or both code and level query params are required", - }, - }); - } - - const candidateConfig = params.config ?? body?.config; - if (!(candidateConfig && Array.isArray(candidateConfig))) { - logger.warn( - "Invalid body: request must include a config array in params.config or body.config", - ); - - return badRequest({ - body: { - code: "INVALID_BODY", - message: - "request must include a config array in params.config or body.config", - }, - }); - } - - const payload = { config: candidateConfig }; - logger.debug( - `Setting configuration for scope: ${ - id ? `id=${id}` : `code=${code}, level=${level}` - }; entries=${candidateConfig.length}`, - ); - - const result = id - ? await setConfiguration(payload, byScopeId(id)) - : await setConfiguration(payload, byCodeAndLevel(code, level)); - - logger.debug( - `Successfully set configuration for scope: ${ - id ? `id=${id}` : `code=${code}, level=${level}` - }`, - ); - return ok({ - body: { result }, - headers: { - "Cache-Control": "no-store", - }, - }); - } catch (error) { - logger.error( - `Something went wrong while setting configuration: ${inspect(error)}`, - ); - - return internalServerError({ - body: { - code: "INTERNAL_ERROR", - message: "An internal server error occurred", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -} diff --git a/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts b/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts new file mode 100644 index 000000000..04eb9c1b7 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts @@ -0,0 +1,178 @@ +/* + * 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, it, vi } from "vitest"; + +import { configRuntimeAction } from "#actions/config"; + +const { + byScopeId, + byCodeAndLevel, + byCode, + getConfiguration, + initialize, + setConfiguration, + getConfigurationVersions, + restoreConfigurationVersion, +} = vi.hoisted(() => ({ + byScopeId: vi.fn((scopeId: string) => ({ + by: { _tag: "scopeId", scopeId }, + })), + byCodeAndLevel: vi.fn((code: string, level: string) => ({ + by: { _tag: "codeAndLevel", code, level }, + })), + byCode: vi.fn((code: string) => ({ + by: { _tag: "code", code }, + })), + getConfiguration: vi.fn(), + initialize: vi.fn(), + setConfiguration: vi.fn(), + getConfigurationVersions: vi.fn(), + restoreConfigurationVersion: vi.fn(), +})); + +vi.mock("@adobe/aio-commerce-lib-config", () => ({ + byCode, + byCodeAndLevel, + byScopeId, + getConfiguration, + getConfigurationVersions, + initialize, + restoreConfigurationVersion, + setConfiguration, +})); + +vi.mock("#config/index", () => ({ + validateCommerceAppConfigDomain: vi.fn((schema: unknown) => schema), +})); + +describe("config runtime action audit routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + + setConfiguration.mockResolvedValue({ + message: "Configuration values updated successfully", + timestamp: "2026-01-01T00:00:00.000Z", + scope: { id: "scope-1", code: "store", level: "store_view" }, + config: [{ name: "foo", value: "bar" }], + }); + getConfiguration.mockResolvedValue({ + scope: { id: "scope-1", code: "store", level: "store_view" }, + config: [{ name: "foo", value: "bar", origin: { code: "store" } }], + }); + getConfigurationVersions.mockResolvedValue({ + scope: { id: "scope-1", code: "store", level: "store_view" }, + versions: [], + pagination: { total: 0, limit: 50, offset: 0 }, + }); + restoreConfigurationVersion.mockResolvedValue({ + message: "Configuration version restored successfully", + timestamp: "2026-01-01T00:00:00.000Z", + scope: { id: "scope-1", code: "store", level: "store_view" }, + restoredVersionId: "ver-2", + }); + }); + + it("returns AUDIT_DISABLED for GET /versions when audit is disabled", async () => { + const main = configRuntimeAction({ configSchema: [] }); + const response = await main({ + __ow_method: "get", + __ow_path: "/versions", + __ow_query: "scopeId=scope-1", + AIO_COMMERCE_CONFIG_AUDIT_ENABLED: "false", + }); + + expect(response.type).toBe("error"); + if (response.type === "error") { + expect(response.error.statusCode).toBe(400); + const { body } = response.error; + expect(body).toBeDefined(); + if (body) { + expect(body.code).toBe("AUDIT_DISABLED"); + } + } + expect(getConfigurationVersions).not.toHaveBeenCalled(); + }); + + it("lists versions from GET /versions with code-only selector", async () => { + const main = configRuntimeAction({ configSchema: [] }); + const response = await main({ + __ow_method: "get", + __ow_path: "/versions", + __ow_query: "code=store&limit=20&offset=5", + }); + + expect(response.type).toBe("success"); + if (response.type === "success") { + expect(response.statusCode).toBe(200); + expect(response.headers?.["Cache-Control"]).toBe("no-store"); + } + + expect(byCode).toHaveBeenCalledWith("store"); + expect(getConfigurationVersions).toHaveBeenCalledWith( + { by: { _tag: "code", code: "store" } }, + { limit: 20, offset: 5 }, + { auditEnabled: true }, + ); + }); + + it("maps restore version not found to INVALID_REQUEST", async () => { + restoreConfigurationVersion.mockRejectedValueOnce( + new Error("VERSION_NOT_FOUND: ver-missing"), + ); + + const main = configRuntimeAction({ configSchema: [] }); + const response = await main({ + __ow_method: "post", + __ow_path: "/versions/restore", + __ow_body: JSON.stringify({ + scopeId: "scope-1", + versionId: "ver-missing", + }), + }); + + expect(response.type).toBe("error"); + if (response.type === "error") { + expect(response.error.statusCode).toBe(400); + const { body } = response.error; + expect(body).toBeDefined(); + if (body) { + expect(body.code).toBe("INVALID_REQUEST"); + expect(body.message).toContain("VERSION_NOT_FOUND"); + } + } + }); + + it("passes auditEnabled into POST / setConfiguration options", async () => { + const main = configRuntimeAction({ configSchema: [] }); + const response = await main({ + __ow_method: "post", + __ow_path: "/", + __ow_body: JSON.stringify({ + scopeId: "scope-1", + config: [{ name: "foo", value: "bar" }], + }), + AIO_COMMERCE_CONFIG_AUDIT_ENABLED: "false", + }); + + expect(response.type).toBe("success"); + expect(byScopeId).toHaveBeenCalledWith("scope-1"); + expect(setConfiguration).toHaveBeenCalledWith( + { config: [{ name: "foo", value: "bar" }] }, + { by: { _tag: "scopeId", scopeId: "scope-1" } }, + { + encryptionKey: undefined, + auditEnabled: false, + }, + ); + }); +}); 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 fa8e02a59..3c15d1d89 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 @@ -15,30 +15,25 @@ import { describe, expect, it } from "vitest"; import { buildBusinessConfigurationExtConfig } from "#commands/generate/actions/config"; describe("buildBusinessConfigurationExtConfig", () => { - it("includes versions and restore actions in business configuration manifest", () => { + it("includes only REST action names in business configuration manifest", () => { const extConfig = buildBusinessConfigurationExtConfig(); const actions = extConfig.runtimeManifest?.packages?.["app-management"]?.actions ?? {}; - expect(actions["get-configuration-versions"]).toBeDefined(); - expect(actions["restore-configuration-version"]).toBeDefined(); + expect(actions.config).toBeDefined(); + expect(actions["scope-tree"]).toBeDefined(); + expect(actions["set-configuration"]).toBeUndefined(); + expect(actions["get-configuration-versions"]).toBeUndefined(); + expect(actions["restore-configuration-version"]).toBeUndefined(); }); - it("wires the audit feature flag input for set/versions/restore actions", () => { + it("wires the audit feature flag input on the config action", () => { const extConfig = buildBusinessConfigurationExtConfig(); const actions = extConfig.runtimeManifest?.packages?.["app-management"]?.actions ?? {}; - expect( - actions["set-configuration"]?.inputs?.AIO_COMMERCE_CONFIG_AUDIT_ENABLED, - ).toBe("$AIO_COMMERCE_CONFIG_AUDIT_ENABLED"); - expect( - actions["get-configuration-versions"]?.inputs - ?.AIO_COMMERCE_CONFIG_AUDIT_ENABLED, - ).toBe("$AIO_COMMERCE_CONFIG_AUDIT_ENABLED"); - expect( - actions["restore-configuration-version"]?.inputs - ?.AIO_COMMERCE_CONFIG_AUDIT_ENABLED, - ).toBe("$AIO_COMMERCE_CONFIG_AUDIT_ENABLED"); + expect(actions.config?.inputs?.AIO_COMMERCE_CONFIG_AUDIT_ENABLED).toBe( + "$AIO_COMMERCE_CONFIG_AUDIT_ENABLED", + ); }); }); From 3d66b71a5741bda8aa72f68c25dd519acc405392 Mon Sep 17 00:00:00 2001 From: Lars Roettig Date: Thu, 5 Mar 2026 10:32:26 +0100 Subject: [PATCH 5/6] fix(config): harden config action/versioning validation and typing Tighten restore/version request handling, improve config action safety (body parsing + log redaction), and align config manager/repository types with persisted payload shape. Also update related docs/tests and versioning serialization edge cases to keep typecheck and runtime behavior consistent. --- .../source/actions/config.ts | 95 ++++- .../test/unit/actions/config.test.ts | 192 ++++++++-- .../source/config-manager.ts | 226 +++++++++--- .../source/config-utils.ts | 2 +- .../configuration/configuration-repository.ts | 3 + .../modules/configuration/set-config.ts | 1 + .../modules/versioning/version-repository.ts | 120 +++++- .../source/types/api.ts | 46 ++- .../test/unit/config-manager.test.ts | 341 ++++++++++++++---- 9 files changed, 855 insertions(+), 171 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/actions/config.ts b/packages/aio-commerce-lib-app/source/actions/config.ts index 3a0060a9c..9a4c25f1c 100644 --- a/packages/aio-commerce-lib-app/source/actions/config.ts +++ b/packages/aio-commerce-lib-app/source/actions/config.ts @@ -33,6 +33,7 @@ import { validateCommerceAppConfigDomain } from "#config/index"; import type { BusinessConfigSchema, ConfigValue, + SetConfigurationRequest, } from "@adobe/aio-commerce-lib-config"; import type { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; import type { BaseContext } from "@aio-commerce-sdk/common-utils/actions"; @@ -187,6 +188,28 @@ function filterPasswordFields>( }); } +/** + * Masks password fields in version change entries (before/after). + * @param schema - The schema to determine password fields. + * @param changes - The change entries to mask. + * @returns The same entries with password before/after replaced by masked value. + */ +function maskPasswordFieldsInChanges< + T extends { name: string; before?: unknown; after?: unknown }, +>(schema: BusinessConfigSchema, changes: T[]): T[] { + return changes.map((entry) => { + const schemaMatch = schema.find((field) => field.name === entry.name); + if (schemaMatch?.type !== "password") { + return entry; + } + return { + ...entry, + ...(entry.before !== undefined ? { before: MASKED_PASSWORD_VALUE } : {}), + ...(entry.after !== undefined ? { after: MASKED_PASSWORD_VALUE } : {}), + }; + }); +} + // The router that will hold the config routes const router = new HttpActionRouter().use(logger()); @@ -233,7 +256,12 @@ router.post("/", { config: v.array( v.object({ name: nonEmptyStringValueSchema("config.name"), - value: v.union([v.string(), v.array(v.string())]), + value: v.union([ + v.string(), + v.number(), + v.boolean(), + v.array(v.string()), + ]), }), ), }), @@ -254,7 +282,9 @@ router.post("/", { ); const result = await setConfiguration( - { config: updatedFields }, + { + config: updatedFields as SetConfigurationRequest["config"], + }, byScopeId(scopeId), { encryptionKey: rawParams.AIO_COMMERCE_CONFIG_ENCRYPTION_KEY, @@ -283,6 +313,7 @@ router.get("/versions", { handler: async (req, ctx) => { const { rawParams } = ctx; + const { configSchema } = rawParams; const auditResult = resolveAuditOrBadRequest(rawParams); if (!auditResult.ok) { return auditResult.response; @@ -308,9 +339,28 @@ router.get("/versions", { { limit, offset }, { auditEnabled }, ); + const versions = result.versions.map((version) => ({ + ...version, + ...(version.config + ? { + config: filterPasswordFields(configSchema, version.config), + } + : {}), + ...(version.changes + ? { + changes: maskPasswordFieldsInChanges( + configSchema, + version.changes, + ), + } + : {}), + })); return ok({ - body: result, + body: { + ...result, + versions, + }, headers: { "Cache-Control": "no-store" }, }); } catch (error) { @@ -330,7 +380,7 @@ router.get("/versions", { }, }); -/** POST /versions/restore - Restore a configuration version */ +/** POST /versions/restore - Restore configuration from a version */ router.post("/versions/restore", { body: v.object({ scopeId: v.optional(nonEmptyStringValueSchema("scopeId")), @@ -341,10 +391,12 @@ router.post("/versions/restore", { expectedLatestVersionId: v.optional( nonEmptyStringValueSchema("expectedLatestVersionId"), ), + fields: v.optional(v.array(nonEmptyStringValueSchema("fields[]"))), }), handler: async (req, ctx) => { const { rawParams } = ctx; + const { configSchema } = rawParams; const auditResult = resolveAuditOrBadRequest(rawParams); if (!auditResult.ok) { return auditResult.response; @@ -353,42 +405,53 @@ router.post("/versions/restore", { const selectorResult = resolveSelectorOrBadRequest( req.body, false, - "Either scopeId/id or both code and level are required for restore", + "Either scopeId/id or code+level in body is required", ); if (!selectorResult.ok) { return selectorResult.response; } - const { auditEnabled } = auditResult; const { selector } = selectorResult; + const { versionId, expectedLatestVersionId, fields } = req.body; + const { auditEnabled } = auditResult; try { const result = await restoreConfigurationVersion( selector, + { versionId, expectedLatestVersionId, fields }, { - versionId: req.body.versionId, - expectedLatestVersionId: req.body.expectedLatestVersionId, + encryptionKey: rawParams.AIO_COMMERCE_CONFIG_ENCRYPTION_KEY, + auditEnabled, }, - { auditEnabled }, ); + result.config = filterPasswordFields(configSchema, result.config); return ok({ body: result, headers: { "Cache-Control": "no-store" }, }); } catch (error) { - if ( - error instanceof Error && - (error.message.includes("VERSION_NOT_FOUND") || - error.message.includes("VERSION_CONFLICT")) - ) { + if (!(error instanceof Error)) { + throw error; + } + if (error.message.startsWith("VERSION_NOT_FOUND:")) { return badRequest({ body: { - code: "INVALID_REQUEST", - message: error.message, + code: "VERSION_NOT_FOUND", + message: "Version not found for the selected scope", }, }); } + if (error.message.startsWith("VERSION_CONFLICT:")) { + return badRequest({ + body: { + code: "VERSION_CONFLICT", + message: + "The latest version does not match expectedLatestVersionId", + }, + }); + } + throw error; } }, diff --git a/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts b/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts index 04eb9c1b7..6183516bc 100644 --- a/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts @@ -14,15 +14,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { configRuntimeAction } from "#actions/config"; +import type { BusinessConfigSchema } from "@adobe/aio-commerce-lib-config"; + const { byScopeId, byCodeAndLevel, byCode, getConfiguration, initialize, + restoreConfigurationVersion, setConfiguration, getConfigurationVersions, - restoreConfigurationVersion, } = vi.hoisted(() => ({ byScopeId: vi.fn((scopeId: string) => ({ by: { _tag: "scopeId", scopeId }, @@ -35,9 +37,9 @@ const { })), getConfiguration: vi.fn(), initialize: vi.fn(), + restoreConfigurationVersion: vi.fn(), setConfiguration: vi.fn(), getConfigurationVersions: vi.fn(), - restoreConfigurationVersion: vi.fn(), })); vi.mock("@adobe/aio-commerce-lib-config", () => ({ @@ -75,10 +77,12 @@ describe("config runtime action audit routes", () => { pagination: { total: 0, limit: 50, offset: 0 }, }); restoreConfigurationVersion.mockResolvedValue({ - message: "Configuration version restored successfully", + message: "Configuration restored successfully", timestamp: "2026-01-01T00:00:00.000Z", scope: { id: "scope-1", code: "store", level: "store_view" }, - restoredVersionId: "ver-2", + restoredFromVersionId: "v-1", + config: [{ name: "foo", value: "bar" }], + removed: [], }); }); @@ -104,6 +108,28 @@ describe("config runtime action audit routes", () => { }); it("lists versions from GET /versions with code-only selector", async () => { + getConfigurationVersions.mockResolvedValueOnce({ + scope: { id: "scope-1", code: "store", level: "store_view" }, + versions: [ + { + id: "v-1", + timestamp: "2026-01-01T00:00:00.000Z", + scope: { id: "scope-1", code: "store", level: "store_view" }, + reason: "set", + change: { + added: ["apiKey", "featureFlag"], + updated: [], + removed: [], + }, + config: [ + { name: "apiKey", value: "secret-1" }, + { name: "featureFlag", value: "on" }, + ], + }, + ], + pagination: { total: 1, limit: 20, offset: 5 }, + }); + const main = configRuntimeAction({ configSchema: [] }); const response = await main({ __ow_method: "get", @@ -125,30 +151,66 @@ describe("config runtime action audit routes", () => { ); }); - it("maps restore version not found to INVALID_REQUEST", async () => { - restoreConfigurationVersion.mockRejectedValueOnce( - new Error("VERSION_NOT_FOUND: ver-missing"), - ); + it("masks password values and keeps non-password values in versions response", async () => { + getConfigurationVersions.mockResolvedValueOnce({ + scope: { id: "scope-1", code: "store", level: "store_view" }, + versions: [ + { + id: "v-2", + timestamp: "2026-01-01T00:00:00.000Z", + scope: { id: "scope-1", code: "store", level: "store_view" }, + reason: "set", + change: { + added: ["apiKey", "featureFlag"], + updated: [], + removed: [], + }, + config: [ + { name: "apiKey", value: "secret-1" }, + { name: "featureFlag", value: "on" }, + ], + changes: [ + { name: "apiKey", after: "secret-1" }, + { name: "featureFlag", after: "on" }, + ], + }, + ], + pagination: { total: 1, limit: 50, offset: 0 }, + }); - const main = configRuntimeAction({ configSchema: [] }); + const configSchema = [ + { name: "apiKey", type: "password" }, + { name: "featureFlag", type: "text" }, + ] as unknown as BusinessConfigSchema; + const main = configRuntimeAction({ configSchema }); const response = await main({ - __ow_method: "post", - __ow_path: "/versions/restore", - __ow_body: JSON.stringify({ - scopeId: "scope-1", - versionId: "ver-missing", - }), + __ow_method: "get", + __ow_path: "/versions", + __ow_query: "scopeId=scope-1", }); - expect(response.type).toBe("error"); - if (response.type === "error") { - expect(response.error.statusCode).toBe(400); - const { body } = response.error; - expect(body).toBeDefined(); - if (body) { - expect(body.code).toBe("INVALID_REQUEST"); - expect(body.message).toContain("VERSION_NOT_FOUND"); - } + expect(response.type).toBe("success"); + if (response.type === "success") { + const body = response.body as + | { + versions?: Array<{ + config?: Array<{ name: string; value: unknown }>; + changes?: Array<{ + name: string; + before?: unknown; + after?: unknown; + }>; + }>; + } + | undefined; + expect(body?.versions?.[0]?.config).toEqual([ + { name: "apiKey", value: "*****" }, + { name: "featureFlag", value: "on" }, + ]); + expect(body?.versions?.[0]?.changes).toEqual([ + { name: "apiKey", after: "*****" }, + { name: "featureFlag", after: "on" }, + ]); } }); @@ -175,4 +237,86 @@ describe("config runtime action audit routes", () => { }, ); }); + + it("restores changed fields from POST /versions/restore and supports fields", async () => { + restoreConfigurationVersion.mockResolvedValueOnce({ + message: "Configuration restored successfully", + timestamp: "2026-01-01T00:00:00.000Z", + scope: { id: "scope-1", code: "store", level: "store_view" }, + restoredFromVersionId: "v-1", + config: [ + { name: "apiKey", value: "secret-1" }, + { name: "featureFlag", value: "on" }, + ], + removed: ["legacyKey"], + }); + + const configSchema = [ + { name: "apiKey", type: "password" }, + { name: "featureFlag", type: "text" }, + ] as unknown as BusinessConfigSchema; + const main = configRuntimeAction({ configSchema }); + const response = await main({ + __ow_method: "post", + __ow_path: "/versions/restore", + __ow_body: JSON.stringify({ + code: "store", + level: "store_view", + versionId: "v-1", + fields: ["apiKey", "featureFlag", "legacyKey"], + }), + }); + + expect(response.type).toBe("success"); + if (response.type === "success") { + expect(response.headers?.["Cache-Control"]).toBe("no-store"); + const body = response.body as + | { + config?: Array<{ name: string; value: string }>; + removed?: string[]; + restoredFromVersionId?: string; + } + | undefined; + expect(body?.config).toEqual([ + { name: "apiKey", value: "*****" }, + { name: "featureFlag", value: "on" }, + ]); + expect(body?.removed).toEqual(["legacyKey"]); + expect(body?.restoredFromVersionId).toBe("v-1"); + } + + expect(byCodeAndLevel).toHaveBeenCalledWith("store", "store_view"); + expect(restoreConfigurationVersion).toHaveBeenCalledWith( + { by: { _tag: "codeAndLevel", code: "store", level: "store_view" } }, + { + versionId: "v-1", + expectedLatestVersionId: undefined, + fields: ["apiKey", "featureFlag", "legacyKey"], + }, + { encryptionKey: undefined, auditEnabled: true }, + ); + }); + + it("returns VERSION_NOT_FOUND for POST /versions/restore", async () => { + restoreConfigurationVersion.mockRejectedValueOnce( + new Error("VERSION_NOT_FOUND: missing"), + ); + + const main = configRuntimeAction({ configSchema: [] }); + const response = await main({ + __ow_method: "post", + __ow_path: "/versions/restore", + __ow_body: JSON.stringify({ + scopeId: "scope-1", + versionId: "v-missing", + }), + }); + + expect(response.type).toBe("error"); + if (response.type === "error") { + expect(response.error.statusCode).toBe(400); + const { body } = response.error; + expect(body?.code).toBe("VERSION_NOT_FOUND"); + } + }); }); diff --git a/packages/aio-commerce-lib-config/source/config-manager.ts b/packages/aio-commerce-lib-config/source/config-manager.ts index c4b1cebda..f773bc317 100644 --- a/packages/aio-commerce-lib-config/source/config-manager.ts +++ b/packages/aio-commerce-lib-config/source/config-manager.ts @@ -21,10 +21,14 @@ import { getConfiguration as getConfigModule, setConfiguration as setConfigModule, } from "./modules/configuration"; -import * as configRepository from "./modules/configuration/configuration-repository"; +import { + loadConfig, + persistConfig, +} from "./modules/configuration/configuration-repository"; import { getSchema as getSchemaModule } from "./modules/schema"; import { getPersistedSchema } from "./modules/schema/config-schema-repository"; import { initializeSchema } from "./modules/schema/initialize"; +import { getPasswordFields } from "./modules/schema/utils"; import { getPersistedScopeTree, getScopeTree as getScopeTreeModule, @@ -38,11 +42,7 @@ import { import type { CommerceHttpClientParams } from "@adobe/aio-commerce-lib-api"; import type { SelectorBy } from "#config-utils"; -import type { - ConfigContext, - ConfigValue, - ConfigValueWithOptionalOrigin, -} from "./modules/configuration/types"; +import type { ConfigContext, ConfigValue } from "./modules/configuration/types"; import type { BusinessConfigSchema } from "./modules/schema"; import type { GetScopeTreeResult, @@ -666,7 +666,11 @@ export async function getConfigurationVersions( } /** - * Restores configuration for a scope from a specific historical version. + * Restores configuration values from a selected version. + * + * Default behavior restores only keys that changed in the selected version + * (`added`, `updated`, `removed`). When `fields` are provided, only those keys + * are processed. Removed keys are applied as deletions from current scope. */ export async function restoreConfigurationVersion( selector: SelectorBy, @@ -677,65 +681,106 @@ export async function restoreConfigurationVersion( assertAuditEnabled(context.auditEnabled); const resolvedScope = await resolveScopeForSelector(selector); - const targetVersion = await getVersionRecord( + const selectedVersion = await getVersionRecord( resolvedScope.scopeCode, request.versionId, ); + if (!selectedVersion) { + throw new Error( + `VERSION_NOT_FOUND: id='${request.versionId}' scope='${resolvedScope.scopeCode}'`, + ); + } + assertVersionSnapshotHasConfig(selectedVersion.snapshot); + + const selectedPayload = toPersistedPayload( + selectedVersion.snapshot, + resolvedScope.scopeId, + resolvedScope.scopeCode, + resolvedScope.scopeLevel, + ); + const currentPayload = + (await loadConfig(resolvedScope.scopeCode)) ?? + toPersistedPayload( + undefined, + resolvedScope.scopeId, + resolvedScope.scopeCode, + resolvedScope.scopeLevel, + ); + + const targetKeys = resolveRestoreTargetKeys(selectedVersion.change, request); + const removedInSelected = new Set(selectedVersion.change.removed); + const selectedByName = new Map( + selectedPayload.config.map((entry) => [entry.name, entry]), + ); + const nextByName = new Map( + currentPayload.config.map((entry) => [entry.name, entry]), + ); + + const restoredConfig: RestoreConfigurationVersionResponse["config"] = []; + const removed: string[] = []; + for (const key of targetKeys) { + if (removedInSelected.has(key)) { + const didDelete = nextByName.delete(key); + if (didDelete) { + removed.push(key); + } + continue; + } + + const versionEntry = selectedByName.get(key); + if (!versionEntry) { + continue; + } - if (!targetVersion) { - throw new Error(`VERSION_NOT_FOUND: ${request.versionId}`); + const normalizedEntry: ConfigValue = { + name: versionEntry.name, + value: versionEntry.value, + origin: { + code: resolvedScope.scopeCode, + level: resolvedScope.scopeLevel, + }, + }; + nextByName.set(key, normalizedEntry); + restoredConfig.push({ + name: normalizedEntry.name, + value: normalizedEntry.value, + }); } - const payload = { + const nextPayload = { scope: { id: resolvedScope.scopeId, code: resolvedScope.scopeCode, level: resolvedScope.scopeLevel, }, - config: extractConfigSnapshot(targetVersion.snapshot), + config: Array.from(nextByName.values()), }; - const restoredVersion = await configRepository.persistConfig( - resolvedScope.scopeCode, - payload, - { - auditEnabled: true, - reason: "restore", - restoredFromVersionId: request.versionId, - expectedLatestVersionId: request.expectedLatestVersionId, - }, - ); - if (!restoredVersion) { - throw new Error("RESTORE_FAILED: could not create restore version"); - } + + const schema = await getSchemaModule(context); + const passwordFieldNames = getPasswordFields(schema); + + await persistConfig(resolvedScope.scopeCode, nextPayload, { + auditEnabled: context.auditEnabled, + reason: "restore", + restoredFromVersionId: selectedVersion.id, + expectedLatestVersionId: request.expectedLatestVersionId, + passwordFieldNames, + }); return { - message: "Configuration version restored successfully", + message: "Configuration restored successfully", timestamp: new Date().toISOString(), scope: { id: resolvedScope.scopeId, code: resolvedScope.scopeCode, level: resolvedScope.scopeLevel, }, - restoredVersionId: restoredVersion.id, + restoredFromVersionId: selectedVersion.id, + config: restoredConfig, + removed, }; } -function extractConfigSnapshot( - snapshot: unknown, -): ConfigValueWithOptionalOrigin[] | ConfigValue[] { - if ( - typeof snapshot !== "object" || - snapshot === null || - !("config" in snapshot) || - !Array.isArray(snapshot.config) - ) { - throw new Error( - "VERSION_SNAPSHOT_INVALID: missing config array in snapshot", - ); - } - return snapshot.config as ConfigValueWithOptionalOrigin[] | ConfigValue[]; -} - /** * Sets the custom scope tree, replacing all existing custom scopes with the provided ones. * @@ -811,3 +856,98 @@ export async function setCustomScopeTree( return await setCustomScopeTreeModule(context, request); } + +function resolveRestoreTargetKeys( + change: { + added: string[]; + updated: string[]; + removed: string[]; + }, + request: RestoreConfigurationVersionRequest, +): string[] { + const requestedFields = + request.fields + ?.map((field) => field.trim()) + .filter((field) => field.length > 0) ?? []; + if (requestedFields.length > 0) { + return Array.from(new Set(requestedFields)); + } + + return Array.from( + new Set([...change.added, ...change.updated, ...change.removed]), + ); +} + +function toPersistedPayload( + payload: unknown, + scopeId: string, + scopeCode: string, + scopeLevel: string, +): { + scope: { + id: string; + code: string; + level: string; + }; + config: ConfigValue[]; +} { + if ( + typeof payload !== "object" || + payload === null || + !("config" in payload) || + !Array.isArray(payload.config) + ) { + return { + scope: { id: scopeId, code: scopeCode, level: scopeLevel }, + config: [], + }; + } + + const normalizedConfig: ConfigValue[] = payload.config + .filter( + ( + entry, + ): entry is { + name: string; + value: ConfigValue["value"]; + origin?: { code?: string; level?: string }; + } => + typeof entry === "object" && + entry !== null && + "name" in entry && + typeof entry.name === "string" && + "value" in entry, + ) + .map((entry) => ({ + name: entry.name, + value: entry.value, + origin: { + code: + entry.origin?.code && entry.origin.code.trim().length > 0 + ? entry.origin.code + : scopeCode, + level: + entry.origin?.level && entry.origin.level.trim().length > 0 + ? entry.origin.level + : scopeLevel, + }, + })); + + return { + scope: { id: scopeId, code: scopeCode, level: scopeLevel }, + config: normalizedConfig, + }; +} + +function assertVersionSnapshotHasConfig(snapshot: unknown): void { + if ( + typeof snapshot !== "object" || + snapshot === null || + !("config" in snapshot) || + !Array.isArray(snapshot.config) + ) { + throw new Error( + "VERSION_SNAPSHOT_INVALID: missing config array in snapshot", + ); + } +} diff --git a/packages/aio-commerce-lib-config/source/config-utils.ts b/packages/aio-commerce-lib-config/source/config-utils.ts index af92d59cd..10e03045b 100644 --- a/packages/aio-commerce-lib-config/source/config-utils.ts +++ b/packages/aio-commerce-lib-config/source/config-utils.ts @@ -249,7 +249,7 @@ export function sanitizeRequestEntries( // TODO: This should be done via schema validation. const hasValidValue = - ["string"].includes(typeof entry.value) || + ["string", "number", "boolean"].includes(typeof entry.value) || (Array.isArray(entry.value) && entry.value.every((item) => typeof item === "string")); 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 bcea4362c..7d2b1e284 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 @@ -23,6 +23,8 @@ type PersistConfigOptions = { reason?: "set" | "restore"; restoredFromVersionId?: string; expectedLatestVersionId?: string; + /** Password field names to exclude from "updated" in version change (avoids always-marked-changed due to re-encryption). */ + passwordFieldNames?: Set; }; type PersistedConfigPayload = { @@ -156,6 +158,7 @@ export async function persistConfig( reason: options.reason ?? "set", restoredFromVersionId: options.restoredFromVersionId, expectedLatestVersionId: options.expectedLatestVersionId, + passwordFieldNames: options.passwordFieldNames, }); createdVersionId = createdVersion.id; } diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts b/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts index c50cb4682..33b320e7d 100644 --- a/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts +++ b/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts @@ -86,6 +86,7 @@ export async function setConfiguration( await configRepository.persistConfig(scopeCode, payload, { auditEnabled: context.auditEnabled, reason: "set", + passwordFieldNames: passwordFields, }); const responseConfig = sanitizedEntries.map((entry) => ({ name: entry.name, diff --git a/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts b/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts index 830365f0d..3a9db7a06 100644 --- a/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts +++ b/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts @@ -14,7 +14,12 @@ import { v7 as uuidv7 } from "uuid"; import { getSharedFiles } from "#utils/repository"; -import type { ConfigurationVersion } from "#types/api"; +import type { + ConfigurationVersion, + ConfigurationVersionChange, + ConfigurationVersionValue, + VersionChangeEntry, +} from "#types/api"; const VERSION_FILE_EXTENSION_REGEX = /\.json$/u; @@ -22,6 +27,8 @@ export type CreateVersionRecordOptions = { reason: "set" | "restore"; restoredFromVersionId?: string; expectedLatestVersionId?: string; + /** Field names of type "password". Excluded from "updated" so they are not always marked changed (encryption produces different ciphertext each time). */ + passwordFieldNames?: Set; }; export type VersionRecord = ConfigurationVersion & { @@ -53,7 +60,11 @@ export async function createVersionRecord( throw new Error("VERSION_CONFLICT: latest version does not match expected"); } - const change = computeVersionChange(latest?.snapshot, payload); + const change = computeVersionChange( + latest?.snapshot, + payload, + options.passwordFieldNames, + ); const normalizedScope = normalizeScope(payload, scopeCode); const record: VersionRecord = { @@ -78,6 +89,7 @@ export async function createVersionRecord( /** * Lists version metadata for a scope with offset/limit paging. + * Loads one extra snapshot when there is a next page so "before" values can be computed for the last version on the page. */ export async function listVersionRecords( scopeCode: string, @@ -92,9 +104,20 @@ export async function listVersionRecords( readVersionSnapshot(scopeCode, versionId), ), ); - const versions = versionCandidates - .filter((version): version is VersionRecord => version !== null) - .map(toVersionMetadata); + const records = versionCandidates.filter( + (version): version is VersionRecord => version !== null, + ); + + const hasNextPage = offset + limit < allVersionIds.length; + const nextVersionId = hasNextPage ? allVersionIds[offset + limit] : null; + const nextRecord = nextVersionId + ? await readVersionSnapshot(scopeCode, nextVersionId) + : null; + + const versions = records.map((record, i) => { + const previousSnapshot = records[i + 1]?.snapshot ?? nextRecord?.snapshot; + return toVersionMetadata(record, previousSnapshot); + }); return { versions, @@ -283,9 +306,86 @@ function normalizeScope( return { id: scopeCode, code: scopeCode, level: "unknown" }; } -function toVersionMetadata(record: VersionRecord): ConfigurationVersion { - const { snapshot: _snapshot, ...metadata } = record; - return metadata; +function toVersionMetadata( + record: VersionRecord, + previousSnapshot?: unknown, +): ConfigurationVersion { + const { snapshot, ...metadata } = record; + const config = extractVersionConfig(snapshot); + const changes = computeVersionDiff(previousSnapshot, snapshot, record.change); + return { + ...metadata, + ...(config.length > 0 ? { config } : {}), + ...(changes.length > 0 ? { changes } : {}), + }; +} + +function extractVersionConfig(snapshot: unknown): ConfigurationVersionValue[] { + if ( + typeof snapshot !== "object" || + snapshot === null || + !("config" in snapshot) || + !Array.isArray(snapshot.config) + ) { + return []; + } + + return snapshot.config + .filter( + (entry): entry is ConfigurationVersionValue => + typeof entry === "object" && + entry !== null && + "name" in entry && + typeof entry.name === "string" && + "value" in entry, + ) + .map((entry) => ({ + name: entry.name, + value: entry.value, + })); +} + +function getConfigValueMap( + snapshot: unknown, +): Map { + const entries = extractVersionConfig(snapshot); + const map = new Map(); + for (const entry of entries) { + map.set(entry.name, entry.value); + } + return map; +} + +function computeVersionDiff( + previousSnapshot: unknown, + currentSnapshot: unknown, + change: ConfigurationVersionChange, +): VersionChangeEntry[] { + const previousMap = getConfigValueMap(previousSnapshot); + const currentMap = getConfigValueMap(currentSnapshot); + const result: VersionChangeEntry[] = []; + + for (const name of change.added) { + result.push({ + name, + after: currentMap.get(name), + }); + } + for (const name of change.updated) { + result.push({ + name, + before: previousMap.get(name), + after: currentMap.get(name), + }); + } + for (const name of change.removed) { + result.push({ + name, + before: previousMap.get(name), + }); + } + + return result; } function sanitizeLimit(limit: number | undefined): number { @@ -344,6 +444,7 @@ function getVersionIdFromPath(path: string): string { function computeVersionChange( previousPayload: unknown, currentPayload: unknown, + passwordFieldNames?: Set, ): ConfigurationVersion["change"] { const previous = getConfigNameValueMap(previousPayload); const current = getConfigNameValueMap(currentPayload); @@ -358,7 +459,8 @@ function computeVersionChange( continue; } - if (previous.get(name) !== value) { + // Do not mark password fields as updated: encrypted value differs every time even when unchanged. + if (previous.get(name) !== value && !passwordFieldNames?.has(name)) { updated.push(name); } } diff --git a/packages/aio-commerce-lib-config/source/types/api.ts b/packages/aio-commerce-lib-config/source/types/api.ts index e792b55b6..c89995bc2 100644 --- a/packages/aio-commerce-lib-config/source/types/api.ts +++ b/packages/aio-commerce-lib-config/source/types/api.ts @@ -100,6 +100,27 @@ export type ConfigurationVersionChange = { removed: string[]; }; +/** Config snapshot entry stored per version. */ +export type ConfigurationVersionValue = { + /** Config field name. */ + name: string; + /** Config field value for this version. */ + value: BusinessConfigSchemaValue; +}; + +/** + * Single key change with before/after values (only changed keys). + * Added: after only; removed: before only; updated: both. + */ +export type VersionChangeEntry = { + /** Config field name. */ + name: string; + /** Value before this version (omitted for added keys). */ + before?: BusinessConfigSchemaValue; + /** Value after this version (omitted for removed keys). */ + after?: BusinessConfigSchemaValue; +}; + /** * Configuration version metadata. */ @@ -120,6 +141,10 @@ export type ConfigurationVersion = { restoredFromVersionId?: string; /** Added/updated/removed key summary for this version. */ change: ConfigurationVersionChange; + /** Snapshot values for this version (name/value only). */ + config?: ConfigurationVersionValue[]; + /** Only changed keys with before/after values. */ + changes?: VersionChangeEntry[]; }; /** @@ -153,13 +178,15 @@ export type GetConfigurationVersionsResponse = { }; /** - * Request body for restoring a configuration version. + * Request type for restoring a configuration version. */ export type RestoreConfigurationVersionRequest = { - /** Version ID to restore from. */ + /** Source version identifier to restore from. */ versionId: string; - /** Optional expected latest version ID for optimistic concurrency control. */ + /** Optional optimistic concurrency control against latest version id. */ expectedLatestVersionId?: string; + /** Optional subset of fields to restore; defaults to changed keys only. */ + fields?: string[]; }; /** @@ -168,7 +195,7 @@ export type RestoreConfigurationVersionRequest = { export type RestoreConfigurationVersionResponse = { /** Success message. */ message: string; - /** ISO timestamp of when restore completed. */ + /** ISO timestamp of when restore was applied. */ timestamp: string; /** Scope information including id, code, and level. */ scope: { @@ -176,8 +203,15 @@ export type RestoreConfigurationVersionResponse = { code: string; level: string; }; - /** Version ID created by the restore operation. */ - restoredVersionId: string; + /** The version id that was used as restore source. */ + restoredFromVersionId: string; + /** Restored values (name/value only). */ + config: Array<{ + name: string; + value: BusinessConfigSchemaValue; + }>; + /** Restored keys removed from current scope. */ + removed: string[]; }; /** diff --git a/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts b/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts index 4a576c62b..4552952c6 100644 --- a/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts +++ b/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts @@ -274,6 +274,34 @@ describe("ConfigManager functions", () => { expect(result.versions[0].change.added).toEqual(["currency"]); expect(result.versions[0].change.updated).toEqual([]); expect(result.versions[0].change.removed).toEqual([]); + expect(result.versions[0].config).toEqual([ + { name: "currency", value: "USD" }, + ]); + expect(result.versions[0].changes).toEqual([ + { name: "currency", after: "USD" }, + ]); + }); + + it("includes before/after in changes when a key is updated", async () => { + await setConfiguration( + { config: [{ name: "currency", value: "USD" }] }, + byCodeAndLevel("global", "global"), + ); + await setConfiguration( + { config: [{ name: "currency", value: "EUR" }] }, + byCodeAndLevel("global", "global"), + ); + + const result = await getConfigurationVersions( + byCodeAndLevel("global", "global"), + ); + + expect(result.versions).toHaveLength(2); + const latest = result.versions[0]; + expect(latest.change.updated).toEqual(["currency"]); + expect(latest.changes).toEqual([ + { name: "currency", before: "USD", after: "EUR" }, + ]); }); it("supports listing versions by code selector without explicit level", async () => { @@ -353,7 +381,46 @@ describe("ConfigManager functions", () => { ).rejects.toThrow("AUDIT_DISABLED"); }); - it("restores a previous version and creates a new restore version entry", async () => { + it("persists config even when version write fails", async () => { + const originalWriteImpl = mockFilesInstance.write.getMockImplementation(); + mockFilesInstance.write.mockImplementation(async (path, content) => { + if (path.includes("/versions/")) { + throw new Error("version write failed"); + } + + if (!originalWriteImpl) { + throw new Error("original write implementation missing"); + } + return await originalWriteImpl(path, content); + }); + + await expect( + setConfiguration( + { config: [{ name: "currency", value: "USD" }] }, + byCodeAndLevel("global", "global"), + ), + ).rejects.toThrow("version write failed"); + + const persisted = await configRepository.getPersistedConfig("global"); + expect.assert(persisted, "persisted config should remain written"); + const parsed = JSON.parse(persisted); + expect( + parsed.config.find((entry: ConfigValue) => entry.name === "currency"), + ).toBeDefined(); + }); + + it("still records versions when version index writes fail", async () => { + const originalWriteImpl = mockFilesInstance.write.getMockImplementation(); + mockFilesInstance.write.mockImplementation(async (path, content) => { + if (path.endsWith("/versions/index.json")) { + throw new Error("index write failed"); + } + if (!originalWriteImpl) { + throw new Error("original write implementation missing"); + } + return await originalWriteImpl(path, content); + }); + await setConfiguration( { config: [{ name: "currency", value: "USD" }] }, byCodeAndLevel("global", "global"), @@ -363,44 +430,229 @@ describe("ConfigManager functions", () => { byCodeAndLevel("global", "global"), ); - const history = await getConfigurationVersions( + const result = await getConfigurationVersions( byCodeAndLevel("global", "global"), ); - const targetVersion = history.versions.find((version) => - version.change.added.includes("currency"), + expect(result.versions).toHaveLength(2); + }); + + it("restores changed keys by default and applies removed keys as deletions", async () => { + await configRepository.persistConfig( + "global", + JSON.parse( + buildPayload("id-global", "global", "global", [ + { + name: "currency", + value: "USD", + origin: { code: "global", level: "global" }, + }, + { + name: "legacy", + value: "legacy-value", + origin: { code: "global", level: "global" }, + }, + ]), + ), + { reason: "set" }, + ); + await configRepository.persistConfig( + "global", + JSON.parse( + buildPayload("id-global", "global", "global", [ + { + name: "currency", + value: "EUR", + origin: { code: "global", level: "global" }, + }, + { + name: "newField", + value: "new-value", + origin: { code: "global", level: "global" }, + }, + ]), + ), + { reason: "set" }, ); - expect.assert(targetVersion, "targetVersion should exist"); + const selectedVersionId = ( + await getConfigurationVersions(byCodeAndLevel("global", "global")) + ).versions[0].id; - await restoreConfigurationVersion(byCodeAndLevel("global", "global"), { - versionId: targetVersion.id, - }); + await setConfiguration( + { + config: [ + { name: "currency", value: "CAD" }, + { name: "legacy", value: "revived" }, + ], + }, + byCodeAndLevel("global", "global"), + ); + + const restored = await restoreConfigurationVersion( + byCodeAndLevel("global", "global"), + { versionId: selectedVersionId }, + ); + expect(restored.restoredFromVersionId).toBe(selectedVersionId); + expect(restored.removed).toEqual(["legacy"]); + expect(restored.config).toHaveLength(2); + expect(restored.config).toEqual( + expect.arrayContaining([ + { name: "currency", value: "EUR" }, + { name: "newField", value: "new-value" }, + ]), + ); - const restored = await getConfiguration(byCodeAndLevel("global", "global")); + const current = await getConfiguration(byCodeAndLevel("global", "global")); expect( - restored.config.find((entry) => entry.name === "currency")?.value, - ).toBe("USD"); + current.config.find((entry) => entry.name === "currency")?.value, + ).toBe("EUR"); + expect( + current.config.find((entry) => entry.name === "newField")?.value, + ).toBe("new-value"); + expect(current.config.find((entry) => entry.name === "legacy")).toBe( + undefined, + ); + }); + + it("restores only requested fields when fields[] is provided", async () => { + await configRepository.persistConfig( + "global", + JSON.parse( + buildPayload("id-global", "global", "global", [ + { + name: "currency", + value: "USD", + origin: { code: "global", level: "global" }, + }, + { + name: "legacy", + value: "legacy-value", + origin: { code: "global", level: "global" }, + }, + ]), + ), + { reason: "set" }, + ); + await configRepository.persistConfig( + "global", + JSON.parse( + buildPayload("id-global", "global", "global", [ + { + name: "currency", + value: "EUR", + origin: { code: "global", level: "global" }, + }, + ]), + ), + { reason: "set" }, + ); + + const selectedVersionId = ( + await getConfigurationVersions(byCodeAndLevel("global", "global")) + ).versions[0].id; + await setConfiguration( + { + config: [ + { name: "currency", value: "CAD" }, + { name: "legacy", value: "revived" }, + ], + }, + byCodeAndLevel("global", "global"), + ); - const afterRestoreHistory = await getConfigurationVersions( + const restored = await restoreConfigurationVersion( byCodeAndLevel("global", "global"), + { versionId: selectedVersionId, fields: ["currency"] }, + ); + expect(restored.removed).toEqual([]); + expect(restored.config).toEqual([{ name: "currency", value: "EUR" }]); + + const current = await getConfiguration(byCodeAndLevel("global", "global")); + expect( + current.config.find((entry) => entry.name === "currency")?.value, + ).toBe("EUR"); + expect(current.config.find((entry) => entry.name === "legacy")?.value).toBe( + "revived", ); - expect(afterRestoreHistory.versions).toHaveLength(3); }); - it("throws when restoring an unknown version", async () => { + it("reports removed keys only when they existed in current scope", async () => { + await configRepository.persistConfig( + "global", + JSON.parse( + buildPayload("id-global", "global", "global", [ + { + name: "currency", + value: "USD", + origin: { code: "global", level: "global" }, + }, + ]), + ), + { reason: "set" }, + ); + await configRepository.persistConfig( + "global", + JSON.parse( + buildPayload("id-global", "global", "global", [ + { + name: "currency", + value: "EUR", + origin: { code: "global", level: "global" }, + }, + { + name: "featureFlag", + value: "on", + origin: { code: "global", level: "global" }, + }, + ]), + ), + { reason: "set" }, + ); + + const selectedVersionId = ( + await getConfigurationVersions(byCodeAndLevel("global", "global")) + ).versions[0].id; + + // Current scope does not contain a key that selected version marked as removed. await setConfiguration( - { config: [{ name: "currency", value: "USD" }] }, + { config: [{ name: "currency", value: "CAD" }] }, + byCodeAndLevel("global", "global"), + ); + + const restored = await restoreConfigurationVersion( byCodeAndLevel("global", "global"), + { versionId: selectedVersionId }, ); + expect(restored.removed).toEqual([]); + }); + + it("throws VERSION_NOT_FOUND when restore source version is missing", async () => { await expect( restoreConfigurationVersion(byCodeAndLevel("global", "global"), { - versionId: "missing-version-id", + versionId: "missing-version", }), ).rejects.toThrow("VERSION_NOT_FOUND"); }); - it("fails restore when stored version snapshot has invalid shape", async () => { + it("throws VERSION_CONFLICT when expectedLatestVersionId does not match", async () => { + await setConfiguration( + { config: [{ name: "currency", value: "USD" }] }, + byCodeAndLevel("global", "global"), + ); + const selectedVersionId = ( + await getConfigurationVersions(byCodeAndLevel("global", "global")) + ).versions[0].id; + + await expect( + restoreConfigurationVersion(byCodeAndLevel("global", "global"), { + versionId: selectedVersionId, + expectedLatestVersionId: "some-other-latest-id", + }), + ).rejects.toThrow("VERSION_CONFLICT"); + }); + + it("fails restore when selected version snapshot has invalid shape", async () => { await setConfiguration( { config: [{ name: "currency", value: "USD" }] }, byCodeAndLevel("global", "global"), @@ -431,61 +683,6 @@ describe("ConfigManager functions", () => { ).rejects.toThrow("VERSION_SNAPSHOT_INVALID"); }); - it("persists config even when version write fails", async () => { - const originalWriteImpl = mockFilesInstance.write.getMockImplementation(); - mockFilesInstance.write.mockImplementation(async (path, content) => { - if (path.includes("/versions/")) { - throw new Error("version write failed"); - } - - if (!originalWriteImpl) { - throw new Error("original write implementation missing"); - } - return await originalWriteImpl(path, content); - }); - - await expect( - setConfiguration( - { config: [{ name: "currency", value: "USD" }] }, - byCodeAndLevel("global", "global"), - ), - ).rejects.toThrow("version write failed"); - - const persisted = await configRepository.getPersistedConfig("global"); - expect.assert(persisted, "persisted config should remain written"); - const parsed = JSON.parse(persisted); - expect( - parsed.config.find((entry: ConfigValue) => entry.name === "currency"), - ).toBeDefined(); - }); - - it("still records versions when version index writes fail", async () => { - const originalWriteImpl = mockFilesInstance.write.getMockImplementation(); - mockFilesInstance.write.mockImplementation(async (path, content) => { - if (path.endsWith("/versions/index.json")) { - throw new Error("index write failed"); - } - if (!originalWriteImpl) { - throw new Error("original write implementation missing"); - } - return await originalWriteImpl(path, content); - }); - - await setConfiguration( - { config: [{ name: "currency", value: "USD" }] }, - byCodeAndLevel("global", "global"), - ); - await setConfiguration( - { config: [{ name: "currency", value: "EUR" }] }, - byCodeAndLevel("global", "global"), - ); - - const result = await getConfigurationVersions( - byCodeAndLevel("global", "global"), - ); - expect(result.versions).toHaveLength(2); - }); - it("merges existing and newly set entries without losing prior values", async () => { // Set initial config await configRepository.saveConfig( From 1b852413694b5bd216811d2a693be7870c0f1ce6 Mon Sep 17 00:00:00 2001 From: Lars Roettig Date: Thu, 5 Mar 2026 18:08:01 +0100 Subject: [PATCH 6/6] Remove feedtuere flag simplify branch --- .../source/actions/config.ts | 73 +------------ .../commands/generate/actions/config.ts | 21 +--- .../test/unit/actions/config.test.ts | 28 +---- .../commands/generate/actions/config.test.ts | 10 -- .../source/config-manager.ts | 29 +++-- .../source/config-utils.ts | 38 ++++--- .../configuration/configuration-repository.ts | 100 ++++++++++++++++-- .../modules/configuration/set-config.ts | 1 - .../source/modules/configuration/types.ts | 7 +- .../source/types/api.ts | 19 ++-- .../source/types/index.ts | 3 - .../test/unit/config-manager.test.ts | 53 +++++----- 12 files changed, 175 insertions(+), 207 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/actions/config.ts b/packages/aio-commerce-lib-app/source/actions/config.ts index 9a4c25f1c..c2cce1851 100644 --- a/packages/aio-commerce-lib-app/source/actions/config.ts +++ b/packages/aio-commerce-lib-app/source/actions/config.ts @@ -47,7 +47,6 @@ type ConfigActionFactoryArgs = { type ConfigActionParams = RuntimeActionParams & ConfigActionFactoryArgs & { AIO_COMMERCE_CONFIG_ENCRYPTION_KEY?: string; - AIO_COMMERCE_CONFIG_AUDIT_ENABLED?: string | boolean; }; /** The context for the config action. */ @@ -58,22 +57,6 @@ interface ConfigActionContext extends BaseContext { // Placeholder value for password fields. const MASKED_PASSWORD_VALUE = "*****"; -function parseAuditEnabled(value: string | boolean | undefined): boolean { - if (typeof value === "boolean") { - return value; - } - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if (normalized === "true") { - return true; - } - if (normalized === "false") { - return false; - } - } - return true; -} - function parseNonNegativeInteger( value: string | number | undefined, name: "limit" | "offset", @@ -114,31 +97,6 @@ function resolveScopeSelector( type ScopeSelector = NonNullable>; type BadRequestResponse = ReturnType; -function auditDisabledResponse() { - return badRequest({ - body: { - code: "AUDIT_DISABLED", - message: "Audit feature is disabled", - }, - }); -} - -function resolveAuditOrBadRequest(rawParams: ConfigActionParams) { - const auditEnabled = parseAuditEnabled( - rawParams.AIO_COMMERCE_CONFIG_AUDIT_ENABLED, - ); - if (!auditEnabled) { - return { - ok: false, - response: auditDisabledResponse(), - } satisfies { ok: false; response: BadRequestResponse }; - } - return { - ok: true, - auditEnabled, - } satisfies { ok: true; auditEnabled: boolean }; -} - function resolveSelectorOrBadRequest( input: { scopeId?: string; @@ -256,12 +214,7 @@ router.post("/", { config: v.array( v.object({ name: nonEmptyStringValueSchema("config.name"), - value: v.union([ - v.string(), - v.number(), - v.boolean(), - v.array(v.string()), - ]), + value: v.string(), }), ), }), @@ -269,9 +222,6 @@ router.post("/", { handler: async (req, ctx) => { const { logger, rawParams } = ctx; const { configSchema } = rawParams; - const auditEnabled = parseAuditEnabled( - rawParams.AIO_COMMERCE_CONFIG_AUDIT_ENABLED, - ); logger.debug(`Setting configuration with scope id: ${req.body.scopeId}`); const { scopeId, config } = req.body; @@ -288,7 +238,6 @@ router.post("/", { byScopeId(scopeId), { encryptionKey: rawParams.AIO_COMMERCE_CONFIG_ENCRYPTION_KEY, - auditEnabled, }, ); @@ -314,10 +263,6 @@ router.get("/versions", { handler: async (req, ctx) => { const { rawParams } = ctx; const { configSchema } = rawParams; - const auditResult = resolveAuditOrBadRequest(rawParams); - if (!auditResult.ok) { - return auditResult.response; - } const selectorResult = resolveSelectorOrBadRequest( req.query, @@ -328,17 +273,15 @@ router.get("/versions", { return selectorResult.response; } - const { auditEnabled } = auditResult; const { selector } = selectorResult; try { const limit = parseNonNegativeInteger(req.query.limit, "limit"); const offset = parseNonNegativeInteger(req.query.offset, "offset"); - const result = await getConfigurationVersions( - selector, - { limit, offset }, - { auditEnabled }, - ); + const result = await getConfigurationVersions(selector, { + limit, + offset, + }); const versions = result.versions.map((version) => ({ ...version, ...(version.config @@ -397,10 +340,6 @@ router.post("/versions/restore", { handler: async (req, ctx) => { const { rawParams } = ctx; const { configSchema } = rawParams; - const auditResult = resolveAuditOrBadRequest(rawParams); - if (!auditResult.ok) { - return auditResult.response; - } const selectorResult = resolveSelectorOrBadRequest( req.body, @@ -413,7 +352,6 @@ router.post("/versions/restore", { const { selector } = selectorResult; const { versionId, expectedLatestVersionId, fields } = req.body; - const { auditEnabled } = auditResult; try { const result = await restoreConfigurationVersion( @@ -421,7 +359,6 @@ router.post("/versions/restore", { { versionId, expectedLatestVersionId, fields }, { encryptionKey: rawParams.AIO_COMMERCE_CONFIG_ENCRYPTION_KEY, - auditEnabled, }, ); result.config = filterPasswordFields(configSchema, result.config); 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 0a3407c87..0f15010f9 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 @@ -24,8 +24,8 @@ import type { CommerceAppConfigOutputModel } from "#config/schema/app"; import type { CommerceAppConfigDomain } from "#config/schema/domains"; type ActionConfig = { + requiresSchema?: boolean; requiresEncryptionKey?: boolean; - requiresAuditFlag?: boolean; }; export type TemplateAction = ActionConfig & { @@ -62,7 +62,7 @@ function createActionDefinition( actionName: string, config: ActionConfig = {}, options: Omit = {}, -): ActionDefinition { +) { const def: ActionDefinition = { ...options, @@ -82,13 +82,6 @@ function createActionDefinition( }; } - if (config.requiresAuditFlag) { - def.inputs = { - ...def.inputs, - AIO_COMMERCE_CONFIG_AUDIT_ENABLED: "$AIO_COMMERCE_CONFIG_AUDIT_ENABLED", - }; - } - return def; } @@ -96,10 +89,7 @@ function createActionDefinition( * Gets the runtime actions to be generated from the ext.config.yaml configuration. * @param extConfig - The ext.config.yaml configuration. */ -export function getRuntimeActions( - extConfig: ExtConfig, - dir: string, -): TemplateAction[] { +export function getRuntimeActions(extConfig: ExtConfig, dir: string) { return Object.entries( extConfig.runtimeManifest?.packages?.[PACKAGE_NAME]?.actions ?? {}, ).map( @@ -117,7 +107,7 @@ export function getRuntimeActions( */ export function buildAppManagementExtConfig( appConfig: CommerceAppConfigOutputModel, -): ExtConfig { +) { const features = getConfigDomains(appConfig); const hasPasswordFieldsInSchema = hasBusinessConfigSchema(appConfig) && @@ -181,13 +171,12 @@ export function buildAppManagementExtConfig( } /** Builds the ext.config.yaml configuration for the business configuration extension. */ -export function buildBusinessConfigurationExtConfig(): ExtConfig { +export function buildBusinessConfigurationExtConfig() { const actions = [ { name: "config", templateFile: "config.js.template", requiresEncryptionKey: true, - requiresAuditFlag: true, }, { name: "scope-tree", diff --git a/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts b/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts index 6183516bc..b2a142099 100644 --- a/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts @@ -86,27 +86,6 @@ describe("config runtime action audit routes", () => { }); }); - it("returns AUDIT_DISABLED for GET /versions when audit is disabled", async () => { - const main = configRuntimeAction({ configSchema: [] }); - const response = await main({ - __ow_method: "get", - __ow_path: "/versions", - __ow_query: "scopeId=scope-1", - AIO_COMMERCE_CONFIG_AUDIT_ENABLED: "false", - }); - - expect(response.type).toBe("error"); - if (response.type === "error") { - expect(response.error.statusCode).toBe(400); - const { body } = response.error; - expect(body).toBeDefined(); - if (body) { - expect(body.code).toBe("AUDIT_DISABLED"); - } - } - expect(getConfigurationVersions).not.toHaveBeenCalled(); - }); - it("lists versions from GET /versions with code-only selector", async () => { getConfigurationVersions.mockResolvedValueOnce({ scope: { id: "scope-1", code: "store", level: "store_view" }, @@ -147,7 +126,6 @@ describe("config runtime action audit routes", () => { expect(getConfigurationVersions).toHaveBeenCalledWith( { by: { _tag: "code", code: "store" } }, { limit: 20, offset: 5 }, - { auditEnabled: true }, ); }); @@ -214,7 +192,7 @@ describe("config runtime action audit routes", () => { } }); - it("passes auditEnabled into POST / setConfiguration options", async () => { + it("passes only encryption options into POST / setConfiguration", async () => { const main = configRuntimeAction({ configSchema: [] }); const response = await main({ __ow_method: "post", @@ -223,7 +201,6 @@ describe("config runtime action audit routes", () => { scopeId: "scope-1", config: [{ name: "foo", value: "bar" }], }), - AIO_COMMERCE_CONFIG_AUDIT_ENABLED: "false", }); expect(response.type).toBe("success"); @@ -233,7 +210,6 @@ describe("config runtime action audit routes", () => { { by: { _tag: "scopeId", scopeId: "scope-1" } }, { encryptionKey: undefined, - auditEnabled: false, }, ); }); @@ -293,7 +269,7 @@ describe("config runtime action audit routes", () => { expectedLatestVersionId: undefined, fields: ["apiKey", "featureFlag", "legacyKey"], }, - { encryptionKey: undefined, auditEnabled: true }, + { encryptionKey: undefined }, ); }); 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 3c15d1d89..d2da6c6f8 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 @@ -26,14 +26,4 @@ describe("buildBusinessConfigurationExtConfig", () => { expect(actions["get-configuration-versions"]).toBeUndefined(); expect(actions["restore-configuration-version"]).toBeUndefined(); }); - - it("wires the audit feature flag input on the config action", () => { - const extConfig = buildBusinessConfigurationExtConfig(); - const actions = - extConfig.runtimeManifest?.packages?.["app-management"]?.actions ?? {}; - - expect(actions.config?.inputs?.AIO_COMMERCE_CONFIG_AUDIT_ENABLED).toBe( - "$AIO_COMMERCE_CONFIG_AUDIT_ENABLED", - ); - }); }); diff --git a/packages/aio-commerce-lib-config/source/config-manager.ts b/packages/aio-commerce-lib-config/source/config-manager.ts index f773bc317..6f50fedc9 100644 --- a/packages/aio-commerce-lib-config/source/config-manager.ts +++ b/packages/aio-commerce-lib-config/source/config-manager.ts @@ -67,7 +67,6 @@ import type { const globalLibConfigOptions: GlobalLibConfigOptions = { cacheTimeout: DEFAULT_CACHE_TIMEOUT, encryptionKey: undefined, - auditEnabled: true, }; /** Options for initializing the configuration library, so that it works as expected. */ @@ -124,8 +123,6 @@ export function setGlobalLibConfigOptions(options: LibConfigOptions) { options.encryptionKey !== undefined ? options.encryptionKey : globalLibConfigOptions.encryptionKey; - globalLibConfigOptions.auditEnabled = - options.auditEnabled ?? globalLibConfigOptions.auditEnabled; } /** @@ -145,16 +142,9 @@ function resolveConfigContext(options?: LibConfigOptions): ConfigContext { options?.encryptionKey !== undefined ? (options.encryptionKey ?? undefined) : (globalLibConfigOptions.encryptionKey ?? undefined), - auditEnabled: options?.auditEnabled ?? globalLibConfigOptions.auditEnabled, }; } -function assertAuditEnabled(auditEnabled: boolean): void { - if (!auditEnabled) { - throw new Error("AUDIT_DISABLED: audit feature is disabled"); - } -} - async function resolveScopeForSelector(selector: SelectorBy): Promise<{ scopeCode: string; scopeLevel: string; @@ -643,11 +633,8 @@ export async function setConfiguration( export async function getConfigurationVersions( selector: SelectorBy, params?: GetConfigurationVersionsParams, - options?: LibConfigOptions, + _options?: LibConfigOptions, ): Promise { - const context = resolveConfigContext(options); - assertAuditEnabled(context.auditEnabled); - const resolvedScope = await resolveScopeForSelector(selector); const page = await listVersionRecords(resolvedScope.scopeCode, params); return { @@ -678,7 +665,6 @@ export async function restoreConfigurationVersion( options?: LibConfigOptions, ): Promise { const context = resolveConfigContext(options); - assertAuditEnabled(context.auditEnabled); const resolvedScope = await resolveScopeForSelector(selector); const selectedVersion = await getVersionRecord( @@ -760,7 +746,6 @@ export async function restoreConfigurationVersion( const passwordFieldNames = getPasswordFields(schema); await persistConfig(resolvedScope.scopeCode, nextPayload, { - auditEnabled: context.auditEnabled, reason: "restore", restoredFromVersionId: selectedVersion.id, expectedLatestVersionId: request.expectedLatestVersionId, @@ -920,7 +905,7 @@ function toPersistedPayload( ) .map((entry) => ({ name: entry.name, - value: entry.value, + value: normalizeConfigValue(entry.value), origin: { code: entry.origin?.code && entry.origin.code.trim().length > 0 @@ -939,6 +924,16 @@ function toPersistedPayload( }; } +function normalizeConfigValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (value === null || value === undefined) { + return ""; + } + return String(value); +} + function assertVersionSnapshotHasConfig(snapshot: unknown): void { if ( typeof snapshot !== "object" || diff --git a/packages/aio-commerce-lib-config/source/config-utils.ts b/packages/aio-commerce-lib-config/source/config-utils.ts index 10e03045b..03fd7a14e 100644 --- a/packages/aio-commerce-lib-config/source/config-utils.ts +++ b/packages/aio-commerce-lib-config/source/config-utils.ts @@ -14,10 +14,7 @@ import { DEFAULT_CUSTOM_SCOPE_LEVEL } from "#utils/constants"; import type { ConfigValue } from "#modules/configuration/index"; import type { ConfigValueWithOptionalOrigin } from "#modules/configuration/types"; -import type { - BusinessConfigSchema, - BusinessConfigSchemaValue, -} from "#modules/schema/index"; +import type { BusinessConfigSchema } from "#modules/schema/index"; import type { ScopeNode, ScopeTree } from "#modules/scope-tree/index"; /** @@ -247,21 +244,30 @@ export function sanitizeRequestEntries( return false; } - // TODO: This should be done via schema validation. - const hasValidValue = - ["string", "number", "boolean"].includes(typeof entry.value) || - (Array.isArray(entry.value) && - entry.value.every((item) => typeof item === "string")); + const hasValidValue = typeof entry.value === "string"; return entry.name.trim().length > 0 && hasValidValue; }) .map((entry) => ({ name: String(entry.name).trim(), - value: entry.value as BusinessConfigSchemaValue, + value: normalizeConfigValue(entry.value), origin: entry.origin, })); } +/** + * Normalizes configuration values to a string representation. + */ +export function normalizeConfigValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (value === null || value === undefined) { + return ""; + } + return String(value); +} + /** * Merge existing and requested entries, setting origin to the current scope for requested entries * @param existingScopeEntries - The existing scope entries. @@ -310,7 +316,7 @@ export function getSchemaDefaults(schema: BusinessConfigSchema) { .filter((field) => field.default !== undefined) .map((field) => ({ name: field.name, - value: field.default as string, + value: normalizeConfigValue(field.default), origin: { code: "default", level: "system" }, })); @@ -332,7 +338,7 @@ function mergeConfigEntries( if (!merged.has(entry.name)) { merged.set(entry.name, { name: entry.name, - value: entry.value, + value: normalizeConfigValue(entry.value), origin, }); } @@ -414,7 +420,7 @@ function mergeCurrentConfigData( if (!merged.has(entry.name)) { merged.set(entry.name, { name: entry.name, - value: entry.value, + value: normalizeConfigValue(entry.value), origin: entry.origin || { code: configData.scope?.code || scopeCode, level: configData.scope?.level || scopeLevel, @@ -432,7 +438,7 @@ function mergeCurrentConfigData( */ function applySchemaDefaults( merged: Map, - defaultMap: Map, + defaultMap: Map, ) { for (const [name, def] of defaultMap.entries()) { if (!merged.has(name)) { @@ -482,10 +488,10 @@ export async function mergeWithSchemaDefaults({ scopePath, }: MergeWithSchemaDefaultsParams) { const schema = await getSchemaFn(); - const defaultMap = new Map(); + const defaultMap = new Map(); for (const field of schema) { if (field.default !== undefined) { - defaultMap.set(field.name, field.default); + defaultMap.set(field.name, normalizeConfigValue(field.default)); } } 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 7d2b1e284..9fc70b6c8 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 @@ -19,7 +19,6 @@ import { getSharedFiles, getSharedState } from "#utils/repository"; import type { ConfigValue } from "./types"; type PersistConfigOptions = { - auditEnabled?: boolean; reason?: "set" | "restore"; restoredFromVersionId?: string; expectedLatestVersionId?: string; @@ -134,7 +133,8 @@ export async function persistConfig( payload: unknown, options: PersistConfigOptions = {}, ): Promise<{ id: string } | null> { - const payloadString = stringify(payload) ?? ""; + const normalizedPayload = normalizePersistedPayload(payload, scopeCode); + const payloadString = stringify(normalizedPayload) ?? ""; const logger = getLogger( "@adobe/aio-commerce-lib-config:configuration-repository", ); @@ -152,20 +152,104 @@ export async function persistConfig( }); } - let createdVersionId: string | null = null; - if (options.auditEnabled !== false) { - const createdVersion = await createVersionRecord(scopeCode, payload, { + const createdVersion = await createVersionRecord( + scopeCode, + normalizedPayload, + { reason: options.reason ?? "set", restoredFromVersionId: options.restoredFromVersionId, expectedLatestVersionId: options.expectedLatestVersionId, passwordFieldNames: options.passwordFieldNames, - }); - createdVersionId = createdVersion.id; - } + }, + ); + const createdVersionId = createdVersion.id; return createdVersionId ? { id: createdVersionId } : null; } +function normalizePersistedPayload( + payload: unknown, + scopeCode: string, +): PersistedConfigPayload { + if ( + typeof payload !== "object" || + payload === null || + !("scope" in payload) || + !("config" in payload) || + typeof payload.scope !== "object" || + payload.scope === null || + !Array.isArray(payload.config) + ) { + return { + scope: { id: scopeCode, code: scopeCode, level: "unknown" }, + config: [], + }; + } + + const scope = payload.scope as { + id?: unknown; + code?: unknown; + level?: unknown; + }; + const configEntries = payload.config + .filter( + ( + entry, + ): entry is { + name: unknown; + value: unknown; + origin?: { code?: unknown; level?: unknown }; + } => + typeof entry === "object" && + entry !== null && + "name" in entry && + "value" in entry, + ) + .map((entry) => { + let originCode = scopeCode; + if (typeof scope.code === "string") { + originCode = scope.code; + } + if (entry.origin && typeof entry.origin.code === "string") { + originCode = entry.origin.code; + } + let originLevel = "unknown"; + if (entry.origin && typeof entry.origin.level === "string") { + originLevel = entry.origin.level; + } else if (typeof scope.level === "string") { + originLevel = scope.level; + } + + return { + name: typeof entry.name === "string" ? entry.name : String(entry.name), + value: normalizeConfigValue(entry.value), + origin: { + code: originCode, + level: originLevel, + }, + }; + }); + + return { + scope: { + id: typeof scope.id === "string" ? scope.id : scopeCode, + code: typeof scope.code === "string" ? scope.code : scopeCode, + level: typeof scope.level === "string" ? scope.level : "unknown", + }, + config: configEntries, + }; +} + +function normalizeConfigValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (value === null || value === undefined) { + return ""; + } + return String(value); +} + /** * Tries to load configuration from state cache. * diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts b/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts index 33b320e7d..f4ef1cf13 100644 --- a/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts +++ b/packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts @@ -84,7 +84,6 @@ export async function setConfiguration( }; await configRepository.persistConfig(scopeCode, payload, { - auditEnabled: context.auditEnabled, reason: "set", passwordFieldNames: passwordFields, }); diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/types.ts b/packages/aio-commerce-lib-config/source/modules/configuration/types.ts index a8b30083d..7cd768e9f 100644 --- a/packages/aio-commerce-lib-config/source/modules/configuration/types.ts +++ b/packages/aio-commerce-lib-config/source/modules/configuration/types.ts @@ -11,7 +11,6 @@ */ import type { SetOptional } from "type-fest"; -import type { BusinessConfigSchemaValue } from "#modules/schema/types"; /** * Represents the origin of a configuration value, indicating which scope it came from. @@ -29,8 +28,8 @@ export type ConfigOrigin = { export type ConfigValue = { /** The name of the configuration field. */ name: string; - /** The configuration value (string, number, boolean, or undefined). */ - value: BusinessConfigSchemaValue; + /** The configuration value (string only). */ + value: string; /** The origin scope where this value was set or inherited from. */ origin: ConfigOrigin; }; @@ -55,6 +54,4 @@ export type ConfigContext = { cacheTimeout: number; /** Optional encryption key for encrypting/decrypting password fields. */ encryptionKey?: string; - /** Whether audit/versioning behavior is enabled for this operation. */ - auditEnabled: boolean; }; diff --git a/packages/aio-commerce-lib-config/source/types/api.ts b/packages/aio-commerce-lib-config/source/types/api.ts index c89995bc2..7bfd32200 100644 --- a/packages/aio-commerce-lib-config/source/types/api.ts +++ b/packages/aio-commerce-lib-config/source/types/api.ts @@ -11,10 +11,7 @@ */ import type { ConfigValue } from "#modules/configuration/types"; -import type { - BusinessConfigSchema, - BusinessConfigSchemaValue, -} from "#modules/schema/types"; +import type { BusinessConfigSchema } from "#modules/schema/types"; export type { GetScopeTreeResult } from "#modules/scope-tree/types"; @@ -62,8 +59,8 @@ export type SetConfigurationRequest = { config: Array<{ /** The name of the configuration field. */ name: string; - /** The value to set (string, number, or boolean). */ - value: BusinessConfigSchemaValue; + /** The value to set (string). */ + value: string; }>; }; @@ -84,7 +81,7 @@ export type SetConfigurationResponse = { /** Array of updated configuration values. */ config: Array<{ name: string; - value: BusinessConfigSchemaValue; + value: string; }>; }; @@ -105,7 +102,7 @@ export type ConfigurationVersionValue = { /** Config field name. */ name: string; /** Config field value for this version. */ - value: BusinessConfigSchemaValue; + value: string; }; /** @@ -116,9 +113,9 @@ export type VersionChangeEntry = { /** Config field name. */ name: string; /** Value before this version (omitted for added keys). */ - before?: BusinessConfigSchemaValue; + before?: string; /** Value after this version (omitted for removed keys). */ - after?: BusinessConfigSchemaValue; + after?: string; }; /** @@ -208,7 +205,7 @@ export type RestoreConfigurationVersionResponse = { /** Restored values (name/value only). */ config: Array<{ name: string; - value: BusinessConfigSchemaValue; + value: string; }>; /** Restored keys removed from current scope. */ removed: string[]; diff --git a/packages/aio-commerce-lib-config/source/types/index.ts b/packages/aio-commerce-lib-config/source/types/index.ts index a92812105..7c9fe1370 100644 --- a/packages/aio-commerce-lib-config/source/types/index.ts +++ b/packages/aio-commerce-lib-config/source/types/index.ts @@ -23,8 +23,6 @@ export type OperationOptions = { export type ConfigOptions = OperationOptions & { /** Optional encryption key for encrypting/decrypting password fields. If not provided, falls back to AIO_COMMERCE_CONFIG_ENCRYPTION_KEY environment variable. */ encryptionKey?: string | null; - /** Optional flag to enable config audit/versioning features. Defaults to true. */ - auditEnabled?: boolean; }; /** Backward-compatible alias for operation and configuration options. */ @@ -37,5 +35,4 @@ export type LibConfigOptions = ConfigOptions; export type GlobalLibConfigOptions = { cacheTimeout: number; encryptionKey?: string | null; - auditEnabled: boolean; }; diff --git a/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts b/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts index 4552952c6..d9a83d26b 100644 --- a/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts +++ b/packages/aio-commerce-lib-config/test/unit/config-manager.test.ts @@ -304,6 +304,29 @@ describe("ConfigManager functions", () => { ]); }); + it("normalizes non-string persisted values to strings before versioning", async () => { + await configRepository.persistConfig( + "global", + { + scope: { id: "id-global", code: "global", level: "global" }, + config: [{ name: "retryCount", value: 3 }], + }, + { reason: "set" }, + ); + + const config = await getConfiguration(byCodeAndLevel("global", "global")); + expect( + config.config.find((entry) => entry.name === "retryCount")?.value, + ).toBe("3"); + + const versions = await getConfigurationVersions( + byCodeAndLevel("global", "global"), + ); + expect(versions.versions[0].config).toEqual([ + { name: "retryCount", value: "3" }, + ]); + }); + it("supports listing versions by code selector without explicit level", async () => { await setConfiguration( { config: [{ name: "currency", value: "USD" }] }, @@ -357,30 +380,6 @@ describe("ConfigManager functions", () => { ); }); - it("skips version creation when audit feature is disabled", async () => { - await setConfiguration( - { config: [{ name: "currency", value: "USD" }] }, - byCodeAndLevel("global", "global"), - { auditEnabled: false }, - ); - - const config = await getConfiguration(byCodeAndLevel("global", "global")); - expect( - config.config.find((entry) => entry.name === "currency")?.value, - ).toBe("USD"); - - const versions = await getConfigurationVersions( - byCodeAndLevel("global", "global"), - ); - expect(versions.versions).toHaveLength(0); - - await expect( - getConfigurationVersions(byCodeAndLevel("global", "global"), undefined, { - auditEnabled: false, - }), - ).rejects.toThrow("AUDIT_DISABLED"); - }); - it("persists config even when version write fails", async () => { const originalWriteImpl = mockFilesInstance.write.getMockImplementation(); mockFilesInstance.write.mockImplementation(async (path, content) => { @@ -736,13 +735,15 @@ describe("ConfigManager functions", () => { expect(response.config).toEqual([{ name: "currency", value: "GBP" }]); }); - it("skips entries missing value and strips unknown props as per request contract", async () => { - // Test malformed entries are handled at runtime + it("accepts only string config values at request boundaries", async () => { + // Test malformed/non-string entries are rejected at runtime const response = await setConfiguration( { config: [ { name: "currency" } as any, // missing value - test runtime handling { name: "exampleList", value: "option1" }, // valid + { name: "featureFlag", value: true } as any, // boolean should be rejected + { name: "retryCount", value: 3 } as any, // number should be rejected { value: "orphaned" } as any, // missing name - test runtime handling ], },