Skip to content

feat(config): add configuration versioning and harden scope/config flows#283

Open
larsroettig wants to merge 7 commits intoadobe:mainfrom
larsroettig:audit
Open

feat(config): add configuration versioning and harden scope/config flows#283
larsroettig wants to merge 7 commits intoadobe:mainfrom
larsroettig:audit

Conversation

@larsroettig
Copy link
Member

@larsroettig larsroettig commented Mar 4, 2026

Summary

  • Add configuration versioning support in aio-commerce-lib-config:
    • create/list/get version records
    • restore configuration from a version snapshot
    • new/expanded API types for versioning responses and requests
  • Harden configuration behavior:
    • reject ambiguous byCode(...) selectors across multiple levels
    • validate restore snapshots and throw explicit VERSION_SNAPSHOT_INVALID
    • preserve config write atomicity (persist config even if version write fails)
    • improve file-not-found handling (ENOENT) and corruption detection
  • Improve generator/templates in aio-commerce-lib-app:
    • add shared audit-enabled.js.template to remove parser duplication
    • add generated actions for:
      • get-configuration-versions
      • restore-configuration-version
    • verify generated output with unit tests

Why

This improves auditability and rollback safety for configuration changes, removes selector ambiguity that could silently target the wrong scope, and increases reliability of persisted state under partial failures.

Scope of Changes

  • packages/aio-commerce-lib-config
    • config manager, utils, repository, versioning module, types, tests/mocks
  • packages/aio-commerce-lib-app
    • action generator config/main, business-configuration templates, tests
  • configs/tsdown/plugins/selective-bundle/helpers.js
    • lint/performance/style fixes

Test Plan

Automated

  • pnpm biome check --write --no-errors-on-unmatched --files-ignore-unknown=true <changed-files>
  • pnpm biome check --no-errors-on-unmatched --files-ignore-unknown=true <changed-files>
  • pnpm exec vitest packages/aio-commerce-lib-config/test/unit/config-manager.test.ts packages/aio-commerce-lib-app/test/unit/commands/generate/actions/config.test.ts
  • Result: 2 test files passed, 23 tests passed

Functional Test Cases

  • Version creation on set

    • Set config on global/global
    • Assert versions list returns new version with expected change.added/updated/removed
  • Version listing by code selector

    • Call versions endpoint with byCode("global")
    • Assert scope resolves and versions return correctly
  • Ambiguous code selector protection

    • Seed scope tree with duplicate code at different levels
    • Call getConfiguration(byCode("dup"))
    • Assert AMBIGUOUS_SCOPE_CODE error
  • Restore success path

    • Create at least two config versions
    • Restore to earlier version
    • Assert config values are rolled back and new restore-version entry is created
  • Restore unknown version

    • Restore with non-existent versionId
    • Assert VERSION_NOT_FOUND
  • Malformed snapshot handling

    • Corrupt stored snapshot shape
    • Attempt restore
    • Assert VERSION_SNAPSHOT_INVALID
  • Atomicity on version write failure

    • Simulate write failure only under /versions/
    • Assert setConfiguration throws
    • Assert main persisted config is still written
  • Index write failure fallback

    • Simulate index write failure
    • Create multiple versions
    • Assert version listing still works from snapshots
  • Template DRY verification

    • Ensure generated actions use shared audit-enabled.js
    • Ensure duplicate parser logic removed from action templates

Risk / Impact

  • Medium impact in config flows due to selector and restore strictness.
  • Low runtime risk from lint/style refactors.
  • Backward compatibility note: byCode(...) now intentionally fails when ambiguous across levels (requires explicit level).

Checklist

  • Unit tests added/updated for new and hardened behavior
  • Lint + format checks passing on changed files
  • No secrets introduced
  • Error messages are explicit and actionable

Checklist:

  • I have signed the Adobe Open Source CLA.
  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have read the DEVELOPMENT document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

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.
Copilot AI review requested due to automatic review settings March 4, 2026 14:25
@github-actions github-actions bot added cfg: tsdown Includes changes in `configs/tsdown` without-changeset The PR does not contain a Changeset file pkg: aio-commerce-lib-config Includes changes in `packages/aio-commerce-lib-config` pkg: aio-commerce-lib-app Includes changes in `packages/aio-commerce-lib-app` labels Mar 4, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds configuration audit/versioning to aio-commerce-lib-config, including version history listing and restore support, while tightening scope selector resolution to avoid ambiguous code-only reads. Also updates generated business-configuration action templates in aio-commerce-lib-app and improves packaging by resolving pnpm catalog: dependency references.

Changes:

  • Introduce a version repository + public APIs/types for listing/restoring configuration versions, with an auditEnabled feature flag.
  • Harden selector resolution by disallowing ambiguous scope-by-code reads and routing code-only selectors through explicit scope-tree resolution.
  • Update generated runtime action templates and generation flow to include versions/restore actions and share audit-flag parsing.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
packages/aio-commerce-lib-config/test/unit/config-manager.test.ts Adds unit coverage for version creation/listing, ambiguity handling, restore flows, and failure modes.
packages/aio-commerce-lib-config/test/mocks/lib-files.ts Makes mock read throw ENOENT for missing files to match prod-style behavior.
packages/aio-commerce-lib-config/source/types/index.ts Adds auditEnabled and global options typing (LibConfigOptions, GlobalLibConfigOptions).
packages/aio-commerce-lib-config/source/types/api.ts Defines API types for version metadata, paging, and restore requests/responses.
packages/aio-commerce-lib-config/source/modules/versioning/version-repository.ts Implements on-disk version snapshot storage, paging, and change summaries.
packages/aio-commerce-lib-config/source/modules/configuration/types.ts Extends ConfigContext with auditEnabled.
packages/aio-commerce-lib-config/source/modules/configuration/set-config.ts Routes persistence through repository with audit/versioning options.
packages/aio-commerce-lib-config/source/modules/configuration/get-config-by-key.ts Tightens return typing for getConfigurationByKey.
packages/aio-commerce-lib-config/source/modules/configuration/configuration-repository.ts Adds version-record creation during persist; improves ENOENT handling and typing.
packages/aio-commerce-lib-config/source/config-utils.ts Changes arg-derivation to only accept (id) or (code, level) and updates optional-level handling.
packages/aio-commerce-lib-config/source/config-manager.ts Adds global config defaults, resolves code-only selectors safely, and exposes versions/restore APIs.
packages/aio-commerce-lib-config/source/commands/index.ts Adds temporary validate schema command wiring for backwards compatibility.
packages/aio-commerce-lib-app/test/unit/commands/generate/actions/config.test.ts Tests that generated business-config manifest includes versions/restore actions and audit input wiring.
packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/set-configuration.js.template New generated action for setting config with audit-flag handling.
packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/restore-configuration-version.js.template New generated action for restoring config versions with conflict support.
packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/get-configuration-versions.js.template New generated action for listing config version history with paging.
packages/aio-commerce-lib-app/source/commands/generate/actions/templates/business-configuration/audit-enabled.js.template Shared helper for parsing the audit-enabled flag in generated actions.
packages/aio-commerce-lib-app/source/commands/generate/actions/main.ts Writes the shared audit-enabled.js helper alongside generated config actions.
packages/aio-commerce-lib-app/source/commands/generate/actions/config.ts Adds requiresAuditFlag input wiring and new business-config actions.
configs/tsdown/plugins/selective-bundle/helpers.js Resolves pnpm catalog: dependency references when building enriched package.json.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines 179 to 183
/**
* Derives the scope information from the provided arguments.
* @param args - The arguments containing either (id), (code), or (code, level).
* @param tree - The scope tree to search for the node.
* @returns The derived scope information including code, level, id, and path.
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc for deriveScopeFromArgs still says it accepts (code) as a single argument, but the implementation now treats a single argument as (id) only and throws unless (id) or (code, level) is provided. Update the docstring to match the supported call shapes.

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +137
/**
* Gets the global encryption key.
* @returns The encryption key or undefined if not set.
* @internal
*/
export function getGlobalLibConfigOptions(): GlobalLibConfigOptions {
return globalLibConfigOptions;
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getGlobalLibConfigOptions() returns the full GlobalLibConfigOptions object (cacheTimeout/encryptionKey/auditEnabled), but the JSDoc says it "Gets the global encryption key" and the @returns description only mentions the encryption key. Please update the comment to reflect what the function actually returns.

Copilot uses AI. Check for mistakes.
Comment on lines 386 to +407
/**
* 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 a boolean indicating whether the scope was found and removed,
* or if it was already not present.
*
* @example
* ```typescript
* import { unsyncCommerceScopes } from "@adobe/aio-commerce-lib-config";
*
* const result = await unsyncCommerceScopes();
* if (result.unsynced) {
* console.log("Commerce scope removed successfully");
* } else {
* console.log("Commerce scope not found");
* try {
* const result = await unsyncCommerceScopes();
*
* if (result) {
* console.log("Commerce scope removed successfully");
* }
* } catch (error) {
* console.error("Failed to unsync commerce scopes:", error);
* }
* ```
*/
export async function unsyncCommerceScopes() {
export async function unsyncCommerceScopes(): Promise<{ unsynced: boolean }> {
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unsyncCommerceScopes JSDoc and example imply the function returns a boolean, but the signature/implementation returns an object { unsynced: boolean }. This mismatch is likely to mislead consumers; either update the docs/example to use result.unsynced or change the return type to a boolean.

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +99
let body;

if (
params &&
typeof params === "object" &&
Object.keys(params).length === 0
) {
body = {};
}

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",
},
});
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

body is never populated from the incoming request body (it’s only set to {} when params is an empty object), so params.config ?? body?.config can never read body.config. Either remove the body?.config fallback (and update the error message), or actually parse the web action body (e.g., from params.__ow_body / params.__ow_body JSON) so HTTP requests work as described.

Copilot uses AI. Check for mistakes.
const inspect = (obj) => util.inspect(obj, { depth: null });

/**
* Get the configuration.
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc summary says "Get the configuration", but this action sets configuration values. Update the comment to avoid confusion for generated action consumers.

Suggested change
* Get the configuration.
* Set configuration values and return the updated configuration.

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +84
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,
});
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expectedLatestVersionId is forwarded without validation. If it’s provided as a non-string (or an empty string), the library may treat it as a conflict or produce confusing errors. Consider validating it as a non-empty string (or omitting it) before calling restoreConfigurationVersion.

Copilot uses AI. Check for mistakes.
return badRequest({
body: {
code: "INVALID_PARAMS",
message: "limit and offset must be positive integers",
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parsePositiveInteger currently accepts 0 (it only rejects values < 0), but the error message says "limit and offset must be positive integers". Either reject 0 or change the message to "non-negative integers" to match the validation.

Suggested change
message: "limit and offset must be positive integers",
message: "limit and offset must be non-negative integers",

Copilot uses AI. Check for mistakes.
"value" in entry &&
typeof entry.name === "string"
) {
entries.set(entry.name, JSON.stringify(entry.value));
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.stringify(entry.value) can return undefined (e.g., when value is undefined), but entries is typed as Map<string, string>. This will fail type-checking and can also produce inconsistent change detection. Consider normalizing the serialized value (e.g., JSON.stringify(...) ?? "null") or using a serializer that guarantees a string.

Suggested change
entries.set(entry.name, JSON.stringify(entry.value));
const serializedValue = JSON.stringify(entry.value);
entries.set(entry.name, serializedValue ?? "null");

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +109
logger.debug(`Setting configuration with payload: ${inspect(payload)}`);

const result = id
? await setConfiguration(payload, byScopeId(id))
: await setConfiguration(payload, byCodeAndLevel(code, level));

logger.debug(`Successfully set configuration: ${inspect(result)}`);
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The debug logging of payload and result here includes the full configuration array, which is expected to contain secrets such as API keys and password-type fields. This causes sensitive configuration values to be written in cleartext to application logs whenever debug logging is enabled, increasing the risk of credential disclosure via log access. Limit logging to non-sensitive identifiers (like scope id/code) or ensure sensitive fields are masked/redacted before being logged.

Suggested change
logger.debug(`Setting configuration with payload: ${inspect(payload)}`);
const result = id
? await setConfiguration(payload, byScopeId(id))
: await setConfiguration(payload, byCodeAndLevel(code, level));
logger.debug(`Successfully set configuration: ${inspect(result)}`);
logger.debug(
`Setting configuration for scope: ${
id ? `id=${id}` : `code=${code}, level=${level}`
}; entries=${Array.isArray(candidateConfig) ? candidateConfig.length : 0}`,
);
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}`
}`,
);

Copilot uses AI. Check for mistakes.
@changeset-bot
Copy link

changeset-bot bot commented Mar 4, 2026

⚠️ No Changeset found

Latest commit: 1b85241

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions github-actions bot removed the cfg: tsdown Includes changes in `configs/tsdown` label Mar 4, 2026
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.
const { selector } = selectorResult;

try {
const limit = parseNonNegativeInteger(req.query.limit, "limit");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my understanding req.query.limit should already be parsed and safe from being a "string", perhaps you need a stricter type here to ensure that it is a positive value:

https://valibot.dev/api/minValue/
https://valibot.dev/api/integer/


try {
const limit = parseNonNegativeInteger(req.query.limit, "limit");
const offset = parseNonNegativeInteger(req.query.offset, "offset");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we should expect a value to be undefined. A

return value;
}
if (value === null || value === undefined) {
return "";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe we should return an "", this could be confused with a string value property that does not have a value set.

Perhaps we could use a different representation here to indicate the value is null.

const latest = await getLatestVersionRecord(scopeCode);

if (
options.expectedLatestVersionId !== undefined &&
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
options.expectedLatestVersionId !== undefined &&
options.expectedLatestVersionId &&

This should be enough to check that it exists.

const filesList = await files.list(versionDir);
return filesList
.map((file) => file.name)
.filter((path) => isVersionSnapshotPath(path, scopeCode))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can omit the first map as it only returns a String[] which is the path

Suggested change
.filter((path) => isVersionSnapshotPath(path, scopeCode))
.filter((file) => isVersionSnapshotPath(file.name, scopeCode))
.map((file) => getVersionIdFromPath(file.path))

params: { limit?: number; offset?: number } = {},
): Promise<VersionPage> {
const allVersionIds = await listVersionIds(scopeCode);
const limit = sanitizeLimit(params.limit);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the route handler this could be handled

): Promise<VersionPage> {
const allVersionIds = await listVersionIds(scopeCode);
const limit = sanitizeLimit(params.limit);
const offset = sanitizeOffset(params.offset);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the route handler this could be handled

/**
* Gets a specific version record (including snapshot) by id.
*/
export async function getVersionRecord(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the idea to export this for usage in a external library? Otherwise it could be inlined as it does not add any business logic that readVersionSnapshot does not already cover.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg: aio-commerce-lib-app Includes changes in `packages/aio-commerce-lib-app` pkg: aio-commerce-lib-config Includes changes in `packages/aio-commerce-lib-config` without-changeset The PR does not contain a Changeset file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants