diff --git a/packages/aio-commerce-lib-app/source/actions/config.ts b/packages/aio-commerce-lib-app/source/actions/config.ts index 1db58b574..c2cce1851 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, @@ -29,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"; @@ -52,6 +57,75 @@ interface ConfigActionContext extends BaseContext { // Placeholder value for password fields. const MASKED_PASSWORD_VALUE = "*****"; +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 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. @@ -72,6 +146,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()); @@ -118,7 +214,7 @@ router.post("/", { config: v.array( v.object({ name: nonEmptyStringValueSchema("config.name"), - value: v.union([v.string(), v.array(v.string())]), + value: v.string(), }), ), }), @@ -136,7 +232,9 @@ router.post("/", { ); const result = await setConfiguration( - { config: updatedFields }, + { + config: updatedFields as SetConfigurationRequest["config"], + }, byScopeId(scopeId), { encryptionKey: rawParams.AIO_COMMERCE_CONFIG_ENCRYPTION_KEY, @@ -151,6 +249,151 @@ 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 { configSchema } = rawParams; + + const selectorResult = resolveSelectorOrBadRequest( + req.query, + true, + "Either scopeId/id or code query param is required", + ); + if (!selectorResult.ok) { + return selectorResult.response; + } + + 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, + }); + 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, + versions, + }, + 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 configuration from a 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"), + ), + fields: v.optional(v.array(nonEmptyStringValueSchema("fields[]"))), + }), + + handler: async (req, ctx) => { + const { rawParams } = ctx; + const { configSchema } = rawParams; + + const selectorResult = resolveSelectorOrBadRequest( + req.body, + false, + "Either scopeId/id or code+level in body is required", + ); + if (!selectorResult.ok) { + return selectorResult.response; + } + + const { selector } = selectorResult; + const { versionId, expectedLatestVersionId, fields } = req.body; + + try { + const result = await restoreConfigurationVersion( + selector, + { versionId, expectedLatestVersionId, fields }, + { + encryptionKey: rawParams.AIO_COMMERCE_CONFIG_ENCRYPTION_KEY, + }, + ); + result.config = filterPasswordFields(configSchema, result.config); + + return ok({ + body: result, + headers: { "Cache-Control": "no-store" }, + }); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + if (error.message.startsWith("VERSION_NOT_FOUND:")) { + return badRequest({ + body: { + 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; + } + }, +}); + /** Factory to create the route handler for the `config` action. */ export const configRuntimeAction = ({ configSchema }: ConfigActionFactoryArgs) => 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..b2a142099 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/actions/config.test.ts @@ -0,0 +1,298 @@ +/* + * 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"; + +import type { BusinessConfigSchema } from "@adobe/aio-commerce-lib-config"; + +const { + byScopeId, + byCodeAndLevel, + byCode, + getConfiguration, + initialize, + restoreConfigurationVersion, + setConfiguration, + getConfigurationVersions, +} = 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(), + restoreConfigurationVersion: vi.fn(), + setConfiguration: vi.fn(), + getConfigurationVersions: 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 restored successfully", + timestamp: "2026-01-01T00:00:00.000Z", + scope: { id: "scope-1", code: "store", level: "store_view" }, + restoredFromVersionId: "v-1", + config: [{ name: "foo", value: "bar" }], + removed: [], + }); + }); + + 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", + __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 }, + ); + }); + + 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 configSchema = [ + { name: "apiKey", type: "password" }, + { name: "featureFlag", type: "text" }, + ] as unknown as BusinessConfigSchema; + const main = configRuntimeAction({ configSchema }); + const response = await main({ + __ow_method: "get", + __ow_path: "/versions", + __ow_query: "scopeId=scope-1", + }); + + 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" }, + ]); + } + }); + + it("passes only encryption options into POST / setConfiguration", 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" }], + }), + }); + + 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, + }, + ); + }); + + 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 }, + ); + }); + + 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-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..d2da6c6f8 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/commands/generate/actions/config.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { describe, expect, it } from "vitest"; + +import { buildBusinessConfigurationExtConfig } from "#commands/generate/actions/config"; + +describe("buildBusinessConfigurationExtConfig", () => { + it("includes only REST action names in business configuration manifest", () => { + const extConfig = buildBusinessConfigurationExtConfig(); + const actions = + extConfig.runtimeManifest?.packages?.["app-management"]?.actions ?? {}; + + 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(); + }); +}); diff --git a/packages/aio-commerce-lib-config/source/commands/index.ts b/packages/aio-commerce-lib-config/source/commands/index.ts index bfe3714de..c46f2ed64 100644 --- a/packages/aio-commerce-lib-config/source/commands/index.ts +++ b/packages/aio-commerce-lib-config/source/commands/index.ts @@ -45,6 +45,11 @@ const COMMANDS = { setup: encryptionSetupCommand, validate: encryptionValidateCommand, }, + // 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 588dfb904..6f50fedc9 100644 --- a/packages/aio-commerce-lib-config/source/config-manager.ts +++ b/packages/aio-commerce-lib-config/source/config-manager.ts @@ -10,8 +10,10 @@ * governing permissions and limitations under the License. */ -import { getPersistedSchema } from "#modules/schema/config-schema-repository"; -import { initializeSchema } from "#modules/schema/initialize"; +import { + deriveScopeFromArgs, + deriveScopeFromCodeAndLevel, +} from "#config-utils"; import { DEFAULT_CACHE_TIMEOUT, DEFAULT_NAMESPACE } from "#utils/constants"; import { @@ -19,27 +21,57 @@ import { getConfiguration as getConfigModule, setConfiguration as setConfigModule, } from "./modules/configuration"; +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, 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 { BusinessConfigSchema } from "#modules/schema/types"; -import type { GetScopeTreeResult, ScopeTree } from "./modules/scope-tree"; +import type { ConfigContext, ConfigValue } from "./modules/configuration/types"; +import type { BusinessConfigSchema } from "./modules/schema"; +import type { + GetScopeTreeResult, + ScopeNode, + ScopeTree, +} from "./modules/scope-tree"; import type { - ConfigOptions, - OperationOptions, + GetConfigurationByKeyResponse, + GetConfigurationResponse, + GetConfigurationVersionsParams, + GetConfigurationVersionsResponse, + GlobalLibConfigOptions, + LibConfigOptions, + RestoreConfigurationVersionRequest, + RestoreConfigurationVersionResponse, SetConfigurationRequest, + SetConfigurationResponse, SetCustomScopeTreeRequest, + SetCustomScopeTreeResponse, } from "./types"; +const globalLibConfigOptions: GlobalLibConfigOptions = { + cacheTimeout: DEFAULT_CACHE_TIMEOUT, + encryptionKey: undefined, +}; + /** Options for initializing the configuration library, so that it works as expected. */ export type InitializeOptions = { - /** Optional schema to use as the source of truth (latest version). If not provided, it will use the stored one (but only if it exists). */ + /** Optional schema to use as the source of truth (latest version). */ schema?: BusinessConfigSchema; }; @@ -47,7 +79,7 @@ export type InitializeOptions = { * Initializes the configuration library so that it works as expected. * @param options - Options for initializing the configuration library. */ -export async function initialize(options: InitializeOptions) { +export async function initialize(options: InitializeOptions): Promise { if (options.schema) { await initializeSchema( { @@ -56,17 +88,120 @@ export async function initialize(options: InitializeOptions) { }, options.schema, ); - return; } const storedSchema = await getPersistedSchema(); - if (!storedSchema) { throw new Error("Schema has never been set before"); } } +/** + * Sets global library configuration options that will be used as defaults for all operations of the library. + * @param options - The library configuration options to set globally. + * @example + * ```typescript + * import { setGlobalLibConfigOptions } from "@adobe/aio-commerce-lib-config"; + * + * // Set a global cache timeout of 5 minutes (300000ms) + * setGlobalLibConfigOptions({ cacheTimeout: 300000 }); + * + * // Set encryption key programmatically instead of using environment variable + * setGlobalLibConfigOptions({ + * encryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + * }); + * + * // All subsequent calls will use this cache timeout unless overridden + * const schema = await getConfigSchema(); + * ``` + */ +export function setGlobalLibConfigOptions(options: LibConfigOptions) { + globalLibConfigOptions.cacheTimeout = + options.cacheTimeout ?? globalLibConfigOptions.cacheTimeout; + globalLibConfigOptions.encryptionKey = + options.encryptionKey !== undefined + ? options.encryptionKey + : globalLibConfigOptions.encryptionKey; +} + +/** + * Gets global library configuration defaults. + * @returns The current global library configuration options. + * @internal + */ +export function getGlobalLibConfigOptions(): GlobalLibConfigOptions { + return globalLibConfigOptions; +} + +function resolveConfigContext(options?: LibConfigOptions): ConfigContext { + return { + namespace: DEFAULT_NAMESPACE, + cacheTimeout: options?.cacheTimeout ?? globalLibConfigOptions.cacheTimeout, + encryptionKey: + options?.encryptionKey !== undefined + ? (options.encryptionKey ?? undefined) + : (globalLibConfigOptions.encryptionKey ?? undefined), + }; +} + +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; @@ -133,23 +268,23 @@ export type GetCachedScopeTreeParams = { // Overload for cached Commerce data export async function getScopeTree( params?: GetCachedScopeTreeParams, - options?: OperationOptions, + options?: LibConfigOptions, ): Promise; // Overload for fresh Commerce data export async function getScopeTree( params: GetFreshScopeTreeParams, - options?: OperationOptions, + options?: LibConfigOptions, ): Promise; // Implementation export async function getScopeTree( params?: GetCachedScopeTreeParams | GetFreshScopeTreeParams, - options?: OperationOptions, -) { + options?: LibConfigOptions, +): Promise { const context = { namespace: DEFAULT_NAMESPACE, - cacheTimeout: options?.cacheTimeout ?? DEFAULT_CACHE_TIMEOUT, + cacheTimeout: options?.cacheTimeout ?? globalLibConfigOptions.cacheTimeout, }; if (params?.refreshData === true) { @@ -202,8 +337,12 @@ export async function getScopeTree( */ export async function syncCommerceScopes( commerceConfig: CommerceHttpClientParams, - options?: OperationOptions, -) { + options?: LibConfigOptions, +): Promise<{ + scopeTree: ScopeTree; + synced: boolean; + error?: string; +}> { try { const result = await getScopeTree( { @@ -237,8 +376,8 @@ export async function syncCommerceScopes( /** * Removes the commerce scope from the persisted scope tree. * - * @returns Promise resolving to an object with `unsynced` boolean indicating whether the - * scope was found and removed, or `false` if it was already not present. + * @returns Promise resolving to an object with `unsynced` indicating whether the scope + * was found and removed. * * @example * ```typescript @@ -252,7 +391,7 @@ export async function syncCommerceScopes( * } * ``` */ -export async function unsyncCommerceScopes() { +export async function unsyncCommerceScopes(): Promise<{ unsynced: boolean }> { const COMMERCE_SCOPE_CODE = "commerce"; const scopeTree = await getPersistedScopeTree(DEFAULT_NAMESPACE); @@ -267,9 +406,58 @@ export async function unsyncCommerceScopes() { // Save updated scope tree await saveScopeTree(DEFAULT_NAMESPACE, updatedScopeTree); + return { unsynced: true }; } +/** + * Gets the configuration schema with lazy initialization and version checking. + * + * The schema defines the structure of configuration fields available in your application, + * including field names, types, default values, and validation rules. The schema is + * cached and automatically updated when the bundled schema version changes. + * + * @param options - Optional library configuration options for cache timeout. + * @returns Promise resolving to an array of schema field definitions. + * + * @example + * ```typescript + * import { getConfigSchema } from "@adobe/aio-commerce-lib-config"; + * + * // Get the configuration schema + * const schema = await getConfigSchema(); + * schema.forEach((field) => { + * console.log(`Field: ${field.name}`); + * console.log(`Type: ${field.type}`); + * console.log(`Default: ${field.default}`); + * }); + * ``` + * + * @example + * ```typescript + * import { getConfigSchema } from "@adobe/aio-commerce-lib-config"; + * + * // Get schema with custom cache timeout + * const schema = await getConfigSchema({ cacheTimeout: 300000 }); + * + * // Find a specific field + * const apiKeyField = schema.find((field) => field.name === "api_key"); + * if (apiKeyField) { + * console.log("API Key field found:", apiKeyField); + * } + * ``` + */ +export function getConfigSchema( + options?: LibConfigOptions, +): Promise { + const context = { + namespace: DEFAULT_NAMESPACE, + cacheTimeout: options?.cacheTimeout ?? globalLibConfigOptions.cacheTimeout, + }; + + return getSchemaModule(context); +} + /** * Gets configuration for a scope. * @@ -302,13 +490,9 @@ export async function unsyncCommerceScopes() { */ export async function getConfiguration( selector: SelectorBy, - options?: ConfigOptions, -) { - const context = { - namespace: DEFAULT_NAMESPACE, - cacheTimeout: options?.cacheTimeout ?? DEFAULT_CACHE_TIMEOUT, - encryptionKey: options?.encryptionKey, - }; + options?: LibConfigOptions, +): Promise { + const context = resolveConfigContext(options); if (selector.by._tag === "scopeId") { return await getConfigModule(context, selector.by.scopeId); @@ -318,7 +502,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); } /** @@ -354,13 +539,9 @@ export async function getConfiguration( export async function getConfigurationByKey( configKey: string, selector: SelectorBy, - options?: ConfigOptions, -) { - const context = { - namespace: DEFAULT_NAMESPACE, - cacheTimeout: options?.cacheTimeout ?? DEFAULT_CACHE_TIMEOUT, - encryptionKey: options?.encryptionKey, - }; + options?: LibConfigOptions, +): Promise { + const context = resolveConfigContext(options); if (selector.by._tag === "scopeId") { return await getConfigByKeyModule(context, configKey, selector.by.scopeId); @@ -375,7 +556,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); } /** @@ -424,13 +606,9 @@ export async function getConfigurationByKey( export async function setConfiguration( request: SetConfigurationRequest, selector: SelectorBy, - options?: ConfigOptions, -) { - const context = { - namespace: DEFAULT_NAMESPACE, - cacheTimeout: options?.cacheTimeout ?? DEFAULT_CACHE_TIMEOUT, - encryptionKey: options?.encryptionKey, - }; + options?: LibConfigOptions, +): Promise { + const context = resolveConfigContext(options); if (selector.by._tag === "scopeId") { return await setConfigModule(context, request, selector.by.scopeId); @@ -445,7 +623,147 @@ 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 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 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, + request: RestoreConfigurationVersionRequest, + options?: LibConfigOptions, +): Promise { + const context = resolveConfigContext(options); + + const resolvedScope = await resolveScopeForSelector(selector); + 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; + } + + 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 nextPayload = { + scope: { + id: resolvedScope.scopeId, + code: resolvedScope.scopeCode, + level: resolvedScope.scopeLevel, + }, + config: Array.from(nextByName.values()), + }; + + const schema = await getSchemaModule(context); + const passwordFieldNames = getPasswordFields(schema); + + await persistConfig(resolvedScope.scopeCode, nextPayload, { + reason: "restore", + restoredFromVersionId: selectedVersion.id, + expectedLatestVersionId: request.expectedLatestVersionId, + passwordFieldNames, + }); + + return { + message: "Configuration restored successfully", + timestamp: new Date().toISOString(), + scope: { + id: resolvedScope.scopeId, + code: resolvedScope.scopeCode, + level: resolvedScope.scopeLevel, + }, + restoredFromVersionId: selectedVersion.id, + config: restoredConfig, + removed, + }; } /** @@ -514,12 +832,117 @@ export async function setConfiguration( */ export async function setCustomScopeTree( request: SetCustomScopeTreeRequest, - options?: OperationOptions, -) { + options?: LibConfigOptions, +): Promise { const context = { namespace: DEFAULT_NAMESPACE, - cacheTimeout: options?.cacheTimeout ?? DEFAULT_CACHE_TIMEOUT, + cacheTimeout: options?.cacheTimeout ?? globalLibConfigOptions.cacheTimeout, }; 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: normalizeConfigValue(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 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" || + 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 caa02f046..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"; /** @@ -170,35 +167,34 @@ 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); } /** * 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. */ -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)"); } /** @@ -248,21 +244,30 @@ export function sanitizeRequestEntries( return false; } - // TODO: This should be done via schema validation. - const hasValidValue = - ["string"].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. @@ -311,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" }, })); @@ -333,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, }); } @@ -349,9 +354,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); @@ -373,9 +379,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", @@ -401,7 +408,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, ) { @@ -410,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, @@ -428,7 +438,7 @@ function mergeCurrentConfigData( */ function applySchemaDefaults( merged: Map, - defaultMap: Map, + defaultMap: Map, ) { for (const [name, def] of defaultMap.entries()) { if (!merged.has(name)) { @@ -443,11 +453,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[]; @@ -474,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 eac92b296..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 @@ -12,16 +12,38 @@ import stringify from "safe-stable-stringify"; +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 = { + 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 = { + scope: { + id: string; + code: string; + level: string; + }; + config: ConfigValue[]; +}; + /** * 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); @@ -43,14 +65,17 @@ 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", ); try { const state = await getSharedState(); const key = getConfigStateKey(scopeCode); - await state.put(key, stringify({ data: payload }) as string); + await state.put(key, stringify({ data: payload }) ?? ""); } catch (error) { logger.debug( "Failed to cache configuration:", @@ -66,20 +91,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; } } @@ -90,7 +113,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); @@ -102,8 +128,13 @@ 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) { - const payloadString = stringify(payload) as string; +export async function persistConfig( + scopeCode: string, + payload: unknown, + options: PersistConfigOptions = {}, +): Promise<{ id: string } | null> { + const normalizedPayload = normalizePersistedPayload(payload, scopeCode); + const payloadString = stringify(normalizedPayload) ?? ""; const logger = getLogger( "@adobe/aio-commerce-lib-config:configuration-repository", ); @@ -120,6 +151,103 @@ export async function persistConfig(scopeCode: string, payload: unknown) { error: e instanceof Error ? e.message : String(e), }); } + + const createdVersion = await createVersionRecord( + scopeCode, + normalizedPayload, + { + reason: options.reason ?? "set", + restoredFromVersionId: options.restoredFromVersionId, + expectedLatestVersionId: options.expectedLatestVersionId, + passwordFieldNames: options.passwordFieldNames, + }, + ); + 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); } /** @@ -128,7 +256,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", ); @@ -152,7 +282,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", ); @@ -162,6 +294,8 @@ async function loadFromPersistedFiles(scopeCode: string) { return null; } + const parsed = JSON.parse(filePayload); + // Try to cache the file data for future reads try { await setCachedConfig(scopeCode, filePayload); @@ -172,7 +306,7 @@ async function loadFromPersistedFiles(scopeCode: string) { }); } - return JSON.parse(filePayload); + return parsed; } catch (err) { if (isNotFoundError(err)) { logger.debug( @@ -194,7 +328,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 3995b8854..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 @@ -83,7 +83,10 @@ export async function setConfiguration( config: mergedScopeConfig, }; - await configRepository.persistConfig(scopeCode, payload); + await configRepository.persistConfig(scopeCode, payload, { + reason: "set", + passwordFieldNames: passwordFields, + }); 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 e6c32e522..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; }; @@ -51,10 +50,8 @@ export type ConfigValueWithOptionalOrigin = SetOptional; export type ConfigContext = { /** The namespace for isolating configuration data. */ namespace: string; - /** Cache timeout in milliseconds. */ cacheTimeout: number; - /** Optional encryption key for encrypting/decrypting password fields. */ encryptionKey?: string; }; 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..3a9db7a06 --- /dev/null +++ b/packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts @@ -0,0 +1,516 @@ +/* + * 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, + ConfigurationVersionChange, + ConfigurationVersionValue, + VersionChangeEntry, +} from "#types/api"; + +const VERSION_FILE_EXTENSION_REGEX = /\.json$/u; + +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 & { + 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, + options.passwordFieldNames, + ); + 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. + * 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, + 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 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, + 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, + 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 { + 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, + passwordFieldNames?: Set, +): 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; + } + + // 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); + } + } + + 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" + ) { + const serializedValue = JSON.stringify(entry.value); + entries.set(entry.name, serializedValue ?? "null"); + } + } + 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..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,10 +81,136 @@ export type SetConfigurationResponse = { /** Array of updated configuration values. */ config: Array<{ name: string; - value: BusinessConfigSchemaValue; + value: string; }>; }; +/** + * 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[]; +}; + +/** Config snapshot entry stored per version. */ +export type ConfigurationVersionValue = { + /** Config field name. */ + name: string; + /** Config field value for this version. */ + value: string; +}; + +/** + * 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?: string; + /** Value after this version (omitted for removed keys). */ + after?: 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; + /** Snapshot values for this version (name/value only). */ + config?: ConfigurationVersionValue[]; + /** Only changed keys with before/after values. */ + changes?: VersionChangeEntry[]; +}; + +/** + * 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 type for restoring a configuration version. + */ +export type RestoreConfigurationVersionRequest = { + /** Source version identifier to restore from. */ + versionId: string; + /** Optional optimistic concurrency control against latest version id. */ + expectedLatestVersionId?: string; + /** Optional subset of fields to restore; defaults to changed keys only. */ + fields?: string[]; +}; + +/** + * Response type for restoring a configuration version. + */ +export type RestoreConfigurationVersionResponse = { + /** Success message. */ + message: string; + /** ISO timestamp of when restore was applied. */ + timestamp: string; + /** Scope information including id, code, and level. */ + scope: { + id: string; + code: string; + level: string; + }; + /** The version id that was used as restore source. */ + restoredFromVersionId: string; + /** Restored values (name/value only). */ + config: Array<{ + name: string; + value: string; + }>; + /** Restored keys removed from current scope. */ + removed: 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 6f0d18df6..7c9fe1370 100644 --- a/packages/aio-commerce-lib-config/source/types/index.ts +++ b/packages/aio-commerce-lib-config/source/types/index.ts @@ -21,6 +21,18 @@ export type OperationOptions = { /** Options for controlling configuration operations. */ export type ConfigOptions = OperationOptions & { - /** Optional encryption key for encrypting/decrypting password fields. */ - encryptionKey?: string; + /** Optional encryption key for encrypting/decrypting password fields. If not provided, falls back to AIO_COMMERCE_CONFIG_ENCRYPTION_KEY environment variable. */ + encryptionKey?: string | null; +}; + +/** Backward-compatible alias for operation and configuration options. */ +export type LibConfigOptions = ConfigOptions; + +/** + * Global fetch options with all properties required. + * @internal + */ +export type GlobalLibConfigOptions = { + cacheTimeout: number; + encryptionKey?: string | null; }; diff --git a/packages/aio-commerce-lib-config/test/mocks/lib-files.ts b/packages/aio-commerce-lib-config/test/mocks/lib-files.ts index 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 0bd40d1a1..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 @@ -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,428 @@ 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([]); + 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("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" }] }, + 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("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("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" }, + ); + + 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 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 current = await getConfiguration(byCodeAndLevel("global", "global")); + expect( + 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 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", + ); + }); + + 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: "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", + }), + ).rejects.toThrow("VERSION_NOT_FOUND"); + }); + + 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"), + ); + 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("merges existing and newly set entries without losing prior values", async () => { // Set initial config await configRepository.saveConfig( @@ -311,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 ], },