diff --git a/.changeset/add-admin-ui-sdk-library.md b/.changeset/add-admin-ui-sdk-library.md new file mode 100644 index 000000000..32f506bec --- /dev/null +++ b/.changeset/add-admin-ui-sdk-library.md @@ -0,0 +1,7 @@ +--- +"@adobe/aio-commerce-lib-admin-ui-sdk": minor +"@adobe/aio-commerce-sdk": minor +--- + +Add `@adobe/aio-commerce-lib-admin-ui-sdk` library for checking Admin UI SDK ACL resource permissions. +Add Admin UI SDK API exports to `@adobe/aio-commerce-sdk`. diff --git a/.changeset/menu-item-acl-resource.md b/.changeset/menu-item-acl-resource.md new file mode 100644 index 000000000..6dc961243 --- /dev/null +++ b/.changeset/menu-item-acl-resource.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-app": minor +--- + +Add optional `aclResource` support to Admin UI SDK menu item configuration. diff --git a/packages/aio-commerce-lib-admin-ui-sdk/biome.jsonc b/packages/aio-commerce-lib-admin-ui-sdk/biome.jsonc new file mode 100644 index 000000000..ac0d12ebd --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/biome.jsonc @@ -0,0 +1,21 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + + "root": false, + "extends": "//", + + "overrides": [ + { + "includes": ["source/**/endpoints.ts"], + "linter": { + "rules": { + "suspicious": { + // Endpoint functions must be async for consistent error handling — + // sync throws would not be catchable via .catch() on the returned Promise. + "useAwait": "off" + } + } + } + } + ] +} diff --git a/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md b/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md new file mode 100644 index 000000000..42b37f5fd --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md @@ -0,0 +1,111 @@ +# `@adobe/aio-commerce-lib-admin-ui-sdk` Documentation + +## Overview + +This package provides utilities for interacting with the Admin UI SDK API: + +- **[Permission Checking](#permission-checking)**: Check whether the current user has access to an ACL resource, with built-in caching and request deduplication +- **[API Client](#api-client)**: Create typed HTTP clients for the Admin UI SDK API + +## API Reference + +For a complete list of all available types, functions, and classes, see the [API Reference](./api-reference/README.md). + +## Quick Start + +### Permission Checking + +Use `getAdminUiSdkPermissionClient` to verify whether the current user has been granted a given ACL resource. Results are cached for 5 minutes by default, and concurrent requests for the same resource are deduplicated automatically. + +```typescript +import { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; +import { getAdminUiSdkPermissionClient } from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; + +const httpClient = new AdobeCommerceHttpClient({ + /* CommerceHttpClientParams */ +}); +const permissions = getAdminUiSdkPermissionClient({ httpClient }); + +// Returns true if granted, false if denied or on error (fail-closed by default) +const allowed = await permissions.check("Vendor_Module::resource_name"); + +// Throws AdminUiSdkPermissionDeniedError if denied, or AdminUiSdkPermissionError if the check fails +await permissions.require("Vendor_Module::resource_name"); + +// Invalidate a cached result (e.g. after a role change) +permissions.invalidate("Vendor_Module::resource_name"); + +// Invalidate all cached results +permissions.invalidate(); +``` + +By default, `check()` fails closed and returns `false` on network or response parsing errors. To opt out of fail-closed behavior for `check()` and receive the underlying error: + +```typescript +const permissions = getAdminUiSdkPermissionClient({ + httpClient, + denyOnError: false, +}); +``` + +To adjust the cache TTL or disable caching entirely: + +```typescript +const permissions = getAdminUiSdkPermissionClient({ + httpClient, + cacheTtlMs: 60_000, // 1 minute +}); + +const permissionsNoCache = getAdminUiSdkPermissionClient({ + httpClient, + cacheTtlMs: 0, // disabled +}); +``` + +#### Error handling + +```typescript +import { + AdminUiSdkPermissionError, + AdminUiSdkPermissionDeniedError, +} from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; + +try { + await permissions.require("Vendor_Module::resource_name"); +} catch (error) { + if (error instanceof AdminUiSdkPermissionDeniedError) { + console.error(`Access denied for resource: ${error.resource}`); + } else if (error instanceof AdminUiSdkPermissionError) { + // Unauthorized, network, or response parsing error + console.error("Permission check failed", error); + } +} +``` + +### API Client + +Use `createAdminUiSdkApiClient` for full access to the Admin UI SDK API: + +```typescript +import { createAdminUiSdkApiClient } from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; + +const client = createAdminUiSdkApiClient({ + baseUrl: "https://commerce.example.com", + // ...other CommerceHttpClientParams +}); +``` + +In install/uninstall actions where only a subset of operations is needed, prefer `createCustomAdminUiSdkApiClient` to keep the bundle lean: + +```typescript +import { + createCustomAdminUiSdkApiClient, + registerExtension, + unregisterExtension, +} from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; + +const client = createCustomAdminUiSdkApiClient(params, { + registerExtension, + unregisterExtension, +}); +``` diff --git a/packages/aio-commerce-lib-admin-ui-sdk/package.json b/packages/aio-commerce-lib-admin-ui-sdk/package.json new file mode 100644 index 000000000..6a33db403 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/package.json @@ -0,0 +1,96 @@ +{ + "name": "@adobe/aio-commerce-lib-admin-ui-sdk", + "type": "module", + "author": "Adobe Inc.", + "version": "0.0.0", + "private": false, + "engines": { + "node": ">=22 <=24" + }, + "license": "Apache-2.0", + "description": "A library to interact with the Adobe Commerce Admin UI SDK API", + "keywords": [ + "aio", + "adobe-io", + "commerce", + "adobe-commerce", + "adobe-commerce-sdk", + "aio-commerce-sdk", + "admin-ui-sdk", + "app-builder" + ], + "bugs": { + "url": "https://github.com/adobe/aio-commerce-sdk/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/adobe/aio-commerce-sdk.git", + "directory": "packages/aio-commerce-lib-admin-ui-sdk" + }, + "publishConfig": { + "exports": { + "./api": { + "import": { + "types": "./dist/es/index.d.mts", + "default": "./dist/es/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + } + }, + "exports": { + "./api": "./source/index.ts", + "./package.json": "./package.json" + }, + "imports": { + "#*": "./source/*.ts", + "#test/*": "./test/*.ts" + }, + "files": [ + "dist", + "package.json", + "CHANGELOG.md", + "README.md" + ], + "scripts": { + "publint": "publint", + "build": "tsdown", + "prepack": "sdk-prepack", + "pack": "pnpm pack", + "postpack": "sdk-postpack", + "assist": "biome check --formatter-enabled=false --linter-enabled=false --assist-enabled=true --no-errors-on-unmatched", + "assist:apply": "biome check --write --formatter-enabled=false --linter-enabled=false --assist-enabled=true --no-errors-on-unmatched", + "check:ci": "biome ci --formatter-enabled=true --linter-enabled=true --assist-enabled=true --no-errors-on-unmatched", + "code:fix": "pnpm run lint:fix && pnpm run assist:apply && pnpm run format && pnpm run format:markdown", + "format": "biome format --write --no-errors-on-unmatched", + "format:markdown": "prettier --no-error-on-unmatched-pattern --write '**/*.md' \"!**/{CODE_OF_CONDUCT.md,COPYRIGHT,LICENSE,SECURITY.md,CONTRIBUTING.md}\"", + "format:check": "biome format --no-errors-on-unmatched", + "lint": "biome lint --no-errors-on-unmatched", + "lint:fix": "biome lint --write --no-errors-on-unmatched", + "typecheck": "tsc --noEmit && echo '✅ No type errors found.'", + "test": "vitest run --coverage", + "test:ui": "vitest --ui --coverage", + "test:watch": "vitest --watch --coverage" + }, + "dependencies": { + "@adobe/aio-commerce-lib-api": "workspace:*", + "@adobe/aio-commerce-lib-core": "workspace:*", + "ky": "catalog:", + "valibot": "catalog:" + }, + "devDependencies": { + "@aio-commerce-sdk/common-utils": "workspace:*", + "@aio-commerce-sdk/config-tsdown": "workspace:*", + "@aio-commerce-sdk/config-typescript": "workspace:*", + "@aio-commerce-sdk/config-vitest": "workspace:*", + "@aio-commerce-sdk/scripting-utils": "workspace:*", + "@aio-commerce-sdk/scripts": "workspace:*", + "msw": "catalog:", + "typescript": "catalog:" + }, + "sideEffects": false +} diff --git a/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/endpoints.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/endpoints.ts new file mode 100644 index 000000000..195fdac7d --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/endpoints.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { parseOrThrow } from "@aio-commerce-sdk/common-utils/valibot"; + +import { + ExtensionRegistrationParamsSchema, + UnregisterExtensionParamsSchema, +} from "./schema"; + +import type { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; +import type { Options } from "ky"; +import type { + ExtensionRegistrationParams, + UnregisterExtensionParams, +} from "./schema"; + +/** + * Registers an Admin UI SDK extension with Commerce via POST /V1/adminuisdk/extension. + * + * @param httpClient - The {@link AdobeCommerceHttpClient} to use to make the request. + * @param params - The extension registration parameters. + * @param fetchOptions - Optional Ky fetch options. + * + * @throws An `HTTPError` if the status code is not 2XX. + */ +export async function registerExtension( + httpClient: AdobeCommerceHttpClient, + params: ExtensionRegistrationParams, + fetchOptions?: Options, +): Promise { + const extension = parseOrThrow(ExtensionRegistrationParamsSchema, params); + + await httpClient.post("adminuisdk/extension", { + ...fetchOptions, + json: { extension }, + }); +} + +/** + * Unregisters an Admin UI SDK extension from Commerce via DELETE /V1/adminuisdk/extension/{workspaceName}/{extensionName}. + * + * @param httpClient - The {@link AdobeCommerceHttpClient} to use to make the request. + * @param params - The workspace and extension names. + * @param fetchOptions - Optional Ky fetch options. + * + * @throws An `HTTPError` if the status code is not 2XX. + */ +export async function unregisterExtension( + httpClient: AdobeCommerceHttpClient, + params: UnregisterExtensionParams, + fetchOptions?: Options, +): Promise { + const { workspaceName, extensionName } = parseOrThrow( + UnregisterExtensionParamsSchema, + params, + ); + + await httpClient.delete( + `adminuisdk/extension/${workspaceName}/${extensionName}`, + fetchOptions, + ); +} diff --git a/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/schema.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/schema.ts new file mode 100644 index 000000000..ecf50fba1 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/schema.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import * as v from "valibot"; + +/** Parameters for POST /V1/adminuisdk/extension. */ +export const ExtensionRegistrationParamsSchema = v.object({ + extensionName: v.string(), + extensionTitle: v.string(), + extensionUrl: v.string(), + extensionWorkspace: v.string(), +}); + +/** Parameters for DELETE /V1/adminuisdk/extension/{workspaceName}/{extensionName}. */ +export const UnregisterExtensionParamsSchema = v.object({ + workspaceName: v.string(), + extensionName: v.string(), +}); + +/** The parameters accepted by POST /V1/adminuisdk/extension. */ +export type ExtensionRegistrationParams = v.InferInput< + typeof ExtensionRegistrationParamsSchema +>; + +/** The parameters accepted by DELETE /V1/adminuisdk/extension/{workspaceName}/{extensionName}. */ +export type UnregisterExtensionParams = v.InferInput< + typeof UnregisterExtensionParamsSchema +>; diff --git a/packages/aio-commerce-lib-admin-ui-sdk/source/api/index.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/api/index.ts new file mode 100644 index 000000000..c4011a7ee --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/api/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** biome-ignore-all lint/performance/noBarrelFile: api sub-barrel */ + +export * from "./extensions/endpoints"; +export * from "./permissions/endpoints"; + +export type * from "./extensions/schema"; +export type * from "./permissions/schema"; diff --git a/packages/aio-commerce-lib-admin-ui-sdk/source/api/permissions/endpoints.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/api/permissions/endpoints.ts new file mode 100644 index 000000000..9ced0ed73 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/api/permissions/endpoints.ts @@ -0,0 +1,49 @@ +/* + * 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 { parseOrThrow } from "@aio-commerce-sdk/common-utils/valibot"; + +import { permissionCheckResponseSchema } from "./schema"; + +import type { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; +import type { Options } from "ky"; +import type { PermissionCheckResponse } from "./schema"; + +/** Parameters for POST /V1/adminuisdk/permission/check. */ +type PermissionCheckParams = { + resource: string; +}; + +/** + * Checks whether the current user has the given ACL resource granted via POST /V1/adminuisdk/permission/check. + * This is the raw HTTP call — prefer {@link getAdminUiSdkPermissionClient} for caching and deduplication. + * + * @param httpClient - The {@link AdobeCommerceHttpClient} to use to make the request. + * @param params - The resource to check. + * @param fetchOptions - Optional Ky fetch options. + * + * @throws An `HTTPError` if the status code is not 2XX. + */ +export async function checkPermission( + httpClient: AdobeCommerceHttpClient, + params: PermissionCheckParams, + fetchOptions?: Options, +): Promise { + const raw = await httpClient + .post("adminuisdk/permission/check", { + ...fetchOptions, + json: { resource: params.resource }, + }) + .json(); + + return parseOrThrow(permissionCheckResponseSchema, raw); +} diff --git a/packages/aio-commerce-lib-admin-ui-sdk/source/api/permissions/schema.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/api/permissions/schema.ts new file mode 100644 index 000000000..d8e4bb66c --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/api/permissions/schema.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import * as v from "valibot"; + +/** Response shape returned by the Admin UI SDK permission check endpoint. */ +export const permissionCheckResponseSchema = v.object({ + allowed: v.boolean(), +}); + +/** Parsed Admin UI SDK permission check response. */ +export type PermissionCheckResponse = v.InferOutput< + typeof permissionCheckResponseSchema +>; diff --git a/packages/aio-commerce-lib-admin-ui-sdk/source/errors.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/errors.ts new file mode 100644 index 000000000..126f5e485 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/errors.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { CommerceSdkErrorBase } from "@adobe/aio-commerce-lib-core/error"; + +/** Base error for Admin UI SDK permission helper failures. */ +export class AdminUiSdkPermissionError extends CommerceSdkErrorBase {} + +/** Error thrown when the current user is denied access to an Admin UI SDK ACL resource. */ +export class AdminUiSdkPermissionDeniedError extends AdminUiSdkPermissionError { + public readonly resource: string; + + public constructor(resource: string, options?: { traceId?: string }) { + super(`Admin UI SDK permission denied for resource: ${resource}`, options); + this.resource = resource; + } +} diff --git a/packages/aio-commerce-lib-admin-ui-sdk/source/index.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/index.ts new file mode 100644 index 000000000..a285035fc --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** biome-ignore-all lint/performance/noBarrelFile: This is the `@adobe/aio-commerce-lib-admin-ui-sdk/api` entrypoint. */ + +export * from "./api/index"; +export * from "./errors"; +export * from "./lib/api-client"; +export * from "./lib/permission-client"; diff --git a/packages/aio-commerce-lib-admin-ui-sdk/source/lib/api-client.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/lib/api-client.ts new file mode 100644 index 000000000..022c7c950 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/lib/api-client.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + AdobeCommerceHttpClient, + ApiClient, +} from "@adobe/aio-commerce-lib-api"; + +import * as extensionEndpoints from "#api/extensions/endpoints"; +import * as permissionEndpoints from "#api/permissions/endpoints"; + +import type { + ApiFunction, + CommerceHttpClientParams, +} from "@adobe/aio-commerce-lib-api"; + +/** + * Creates a new API client for the Admin UI SDK API with all available operations. + * Prefer {@link createCustomAdminUiSdkApiClient} for bundle-size-aware contexts. + * + * @param params - The parameters to build the Commerce HTTP client. + */ +export function createAdminUiSdkApiClient(params: CommerceHttpClientParams) { + return ApiClient.create(new AdobeCommerceHttpClient(params), { + ...extensionEndpoints, + ...permissionEndpoints, + }); +} + +/** An API client for the Admin UI SDK API with all operations. */ +export type AdminUiSdkApiClient = ReturnType; + +/** + * Creates a customized Admin UI SDK API client with only the specified operations. + * Use this in install/uninstall contexts where only a subset of operations is needed. + * + * @param params - The parameters to build the Commerce HTTP client. + * @param functions - The API functions to include in the client. + */ +export function createCustomAdminUiSdkApiClient< + TFunctions extends Record< + string, + // biome-ignore lint/suspicious/noExplicitAny: We can't know the type of the argument/return type. + ApiFunction + >, +>(params: CommerceHttpClientParams, functions: TFunctions) { + return ApiClient.create(new AdobeCommerceHttpClient(params), functions); +} diff --git a/packages/aio-commerce-lib-admin-ui-sdk/source/lib/permission-client.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/lib/permission-client.ts new file mode 100644 index 000000000..cba235cd8 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/lib/permission-client.ts @@ -0,0 +1,185 @@ +/* + * 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 { HTTPError } from "ky"; + +import { checkPermission } from "#api/permissions/endpoints"; +import { + AdminUiSdkPermissionDeniedError, + AdminUiSdkPermissionError, +} from "#errors"; + +import type { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; + +const DEFAULT_CACHE_TTL_MS = 300_000; + +/** Options used to create an Admin UI SDK permission client. */ +export interface AdminUiSdkPermissionClientOptions { + /** Milliseconds to cache a permission result. Default: 300_000 (5 minutes). Set to 0 to disable caching. */ + cacheTtlMs?: number; + /** Return false instead of throwing when a network or parse error occurs. Default: true. */ + denyOnError?: boolean; + /** Commerce HTTP client used to call the Admin UI SDK permission endpoint. */ + httpClient: AdobeCommerceHttpClient; +} + +/** Client for checking the current user's Admin UI SDK resource permissions. */ +export interface AdminUiSdkPermissionClient { + /** + * Returns `true` if the current user has the given resource granted, `false` if denied. + * Returns `false` on network or parse errors when `denyOnError: true` (default). + * Always throws `AdminUiSdkPermissionError` on 401, regardless of `denyOnError`. + */ + check(resource: string): Promise; + /** + * Clears the cached result for `resource`. If called without an argument, clears + * all cached entries and in-flight tracking without aborting outstanding HTTP requests. + */ + invalidate(resource?: string): void; + /** + * Resolves when the current user has the given resource granted. + * Throws `AdminUiSdkPermissionDeniedError` if denied. + * Throws `AdminUiSdkPermissionError` on 401, network, and parse errors. + */ + require(resource: string): Promise; +} + +type PermissionCheckResult = + | { + allowed: boolean; + cacheable: true; + } + | { + error: AdminUiSdkPermissionError; + cacheable: false; + }; + +function isUnauthorizedError(error: unknown) { + return error instanceof HTTPError && error.response.status === 401; +} + +function toPermissionError(error: unknown) { + return error instanceof AdminUiSdkPermissionError + ? error + : new AdminUiSdkPermissionError("Permission check failed", { + cause: error, + }); +} + +/** Creates a client for checking Admin UI SDK ACL resources. */ +export function getAdminUiSdkPermissionClient( + options: AdminUiSdkPermissionClientOptions, +): AdminUiSdkPermissionClient { + const { + httpClient, + cacheTtlMs = DEFAULT_CACHE_TTL_MS, + denyOnError = true, + } = options; + const cache = new Map(); + const inFlight = new Map>(); + + async function fetchCheck(resource: string): Promise { + try { + const result = await checkPermission(httpClient, { resource }); + + return { + allowed: result.allowed, + cacheable: true, + }; + } catch (error) { + if (isUnauthorizedError(error)) { + throw new AdminUiSdkPermissionError("Unauthorized", { cause: error }); + } + + if (denyOnError) { + return { + error: toPermissionError(error), + cacheable: false, + }; + } + + throw toPermissionError(error); + } + } + + function resolveCheck(resource: string) { + if (cacheTtlMs > 0) { + const cached = cache.get(resource); + + if (cached !== undefined && cached.expiresAt > Date.now()) { + return { + allowed: cached.value, + cacheable: true, + }; + } + } + + const existing = inFlight.get(resource); + + if (existing !== undefined) { + return existing; + } + + const promise = fetchCheck(resource); + const trackedPromise = promise + .then((result) => { + if ( + cacheTtlMs > 0 && + result.cacheable && + inFlight.get(resource) === trackedPromise + ) { + cache.set(resource, { + value: result.allowed, + expiresAt: Date.now() + cacheTtlMs, + }); + } + + return result; + }) + .finally(() => { + if (inFlight.get(resource) === trackedPromise) { + inFlight.delete(resource); + } + }); + + inFlight.set(resource, trackedPromise); + return trackedPromise; + } + + return { + async check(resource: string) { + const result = await resolveCheck(resource); + return "error" in result ? false : result.allowed; + }, + async require(resource: string) { + const result = await resolveCheck(resource); + + if ("error" in result) { + throw result.error; + } + + if (!result.allowed) { + throw new AdminUiSdkPermissionDeniedError(resource); + } + }, + invalidate(resource?: string) { + if (resource === undefined) { + cache.clear(); + inFlight.clear(); + return; + } + + cache.delete(resource); + inFlight.delete(resource); + }, + }; +} diff --git a/packages/aio-commerce-lib-admin-ui-sdk/test/fixtures/http-client.ts b/packages/aio-commerce-lib-admin-ui-sdk/test/fixtures/http-client.ts new file mode 100644 index 000000000..275886d6e --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/test/fixtures/http-client.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; + +import type { CommerceHttpClientParams } from "@adobe/aio-commerce-lib-api"; + +export const BASE_URL = "https://commerce.test"; + +export const TEST_CLIENT_PARAMS: CommerceHttpClientParams = { + config: { baseUrl: BASE_URL, flavor: "paas" as const }, + auth: { + accessToken: "test-access-token", + accessTokenSecret: "test-access-token-secret", + consumerKey: "test-consumer-key", + consumerSecret: "test-consumer-secret", + }, + fetchOptions: { retry: 0 }, +}; + +export class TestAdobeCommerceHttpClient extends AdobeCommerceHttpClient { + public constructor( + params: CommerceHttpClientParams, + mockFetch: typeof fetch, + ) { + super(params); + const client = this.httpClient.extend({ + fetch: mockFetch as unknown as typeof globalThis.fetch, + }); + this.setHttpClient(client); + } +} + +export function makeHttpClient(mockFetch: typeof fetch) { + return new TestAdobeCommerceHttpClient(TEST_CLIENT_PARAMS, mockFetch); +} diff --git a/packages/aio-commerce-lib-admin-ui-sdk/test/unit/api/extensions/endpoints.test.ts b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/api/extensions/endpoints.test.ts new file mode 100644 index 000000000..14641dab1 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/api/extensions/endpoints.test.ts @@ -0,0 +1,95 @@ +/* + * 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, vi } from "vitest"; + +import { + registerExtension, + unregisterExtension, +} from "#api/extensions/endpoints"; +import { BASE_URL, makeHttpClient } from "#test/fixtures/http-client"; + +const REGISTER_URL = `${BASE_URL}/rest/all/V1/adminuisdk/extension`; +const UNREGISTER_URL = `${BASE_URL}/rest/all/V1/adminuisdk/extension/prod-workspace/my-namespace`; + +const PARAMS = { + extensionName: "my-namespace", + extensionTitle: "My App", + extensionUrl: "https://my-namespace.adobeio-static.net/index.html", + extensionWorkspace: "prod-workspace", +}; + +describe("registerExtension", () => { + it("POSTs to /V1/adminuisdk/extension with extension body and resolves without reading a response body", async () => { + let capturedBody: unknown; + let capturedUrl = ""; + + const fetchMock = vi.fn(async (input: Request) => { + capturedUrl = input.url; + capturedBody = await input.clone().json(); + return new Response(null, { status: 204 }); + }); + + const result = await registerExtension( + makeHttpClient(fetchMock as typeof fetch), + PARAMS, + ); + + expect(result).toBeUndefined(); + expect(capturedUrl).toBe(REGISTER_URL); + expect(capturedBody).toEqual({ extension: PARAMS }); + }); + + it("throws on non-2xx response", async () => { + const fetchMock = vi.fn(async () => + Response.json({ message: "Unauthorized" }, { status: 401 }), + ); + + await expect( + registerExtension(makeHttpClient(fetchMock as typeof fetch), PARAMS), + ).rejects.toThrow(); + }); +}); + +describe("unregisterExtension", () => { + it("sends DELETE to /V1/adminuisdk/extension/{workspaceName}/{extensionName}", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + + const fetchMock = vi.fn((input: Request) => { + capturedUrl = input.url; + capturedMethod = input.method; + return Promise.resolve(new Response(null, { status: 200 })); + }); + + await unregisterExtension(makeHttpClient(fetchMock as typeof fetch), { + workspaceName: "prod-workspace", + extensionName: "my-namespace", + }); + + expect(capturedMethod).toBe("DELETE"); + expect(capturedUrl).toBe(UNREGISTER_URL); + }); + + it("throws on non-2xx response", async () => { + const fetchMock = vi.fn(async () => + Response.json({ message: "Not Found" }, { status: 404 }), + ); + + await expect( + unregisterExtension(makeHttpClient(fetchMock as typeof fetch), { + workspaceName: "prod-workspace", + extensionName: "my-namespace", + }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/aio-commerce-lib-admin-ui-sdk/test/unit/api/permissions/endpoints.test.ts b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/api/permissions/endpoints.test.ts new file mode 100644 index 000000000..954308c00 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/api/permissions/endpoints.test.ts @@ -0,0 +1,77 @@ +/* + * 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, vi } from "vitest"; + +import { checkPermission } from "#api/permissions/endpoints"; +import { BASE_URL, makeHttpClient } from "#test/fixtures/http-client"; + +const CHECK_URL = `${BASE_URL}/rest/all/V1/adminuisdk/permission/check`; + +describe("checkPermission", () => { + it("POSTs { resource } to /V1/adminuisdk/permission/check and returns { allowed: true }", async () => { + let capturedBody: unknown; + let capturedUrl = ""; + + const fetchMock = vi.fn(async (input: Request) => { + capturedUrl = input.url; + capturedBody = await input.clone().json(); + return Response.json({ allowed: true }); + }); + + const result = await checkPermission( + makeHttpClient(fetchMock as typeof fetch), + { + resource: "Acme_Promotions::dashboard", + }, + ); + + expect(result).toEqual({ allowed: true }); + expect(capturedUrl).toBe(CHECK_URL); + expect(capturedBody).toEqual({ resource: "Acme_Promotions::dashboard" }); + }); + + it("returns { allowed: false } when server responds with false", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: false })); + + const result = await checkPermission( + makeHttpClient(fetchMock as typeof fetch), + { + resource: "Acme_Promotions::dashboard", + }, + ); + + expect(result).toEqual({ allowed: false }); + }); + + it("throws on non-2xx response", async () => { + const fetchMock = vi.fn(async () => + Response.json({ message: "Unauthorized" }, { status: 401 }), + ); + + await expect( + checkPermission(makeHttpClient(fetchMock as typeof fetch), { + resource: "Acme_Promotions::dashboard", + }), + ).rejects.toThrow(); + }); + + it("throws when the response shape is invalid", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: "yes" })); + + await expect( + checkPermission(makeHttpClient(fetchMock as typeof fetch), { + resource: "Acme_Promotions::dashboard", + }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/aio-commerce-lib-admin-ui-sdk/test/unit/errors.test.ts b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/errors.test.ts new file mode 100644 index 000000000..c5d39f6c3 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/errors.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { CommerceSdkErrorBase } from "@adobe/aio-commerce-lib-core/error"; +import { describe, expect, it } from "vitest"; + +import { + AdminUiSdkPermissionDeniedError, + AdminUiSdkPermissionError, +} from "#errors"; + +describe("AdminUiSdkPermissionError", () => { + it("extends CommerceSdkErrorBase", () => { + const error = new AdminUiSdkPermissionError("something went wrong"); + + expect(error).toBeInstanceOf(CommerceSdkErrorBase); + expect(error).toBeInstanceOf(AdminUiSdkPermissionError); + expect(error.message).toBe("something went wrong"); + }); + + it("forwards traceId", () => { + const error = new AdminUiSdkPermissionError("err", { + traceId: "trace-abc", + }); + + expect(error.traceId).toBe("trace-abc"); + }); + + it("is recognised by CommerceSdkErrorBase.isSdkError", () => { + expect( + CommerceSdkErrorBase.isSdkError(new AdminUiSdkPermissionError("err")), + ).toBe(true); + }); +}); + +describe("AdminUiSdkPermissionDeniedError", () => { + it("extends AdminUiSdkPermissionError", () => { + const error = new AdminUiSdkPermissionDeniedError( + "Acme_Promotions::dashboard", + ); + + expect(error).toBeInstanceOf(AdminUiSdkPermissionError); + expect(error).toBeInstanceOf(AdminUiSdkPermissionDeniedError); + }); + + it("includes the resource in the message", () => { + const error = new AdminUiSdkPermissionDeniedError( + "Acme_Promotions::dashboard", + ); + + expect(error.message).toContain("Acme_Promotions::dashboard"); + }); + + it("exposes the resource property", () => { + const error = new AdminUiSdkPermissionDeniedError( + "Acme_Promotions::dashboard", + ); + + expect(error.resource).toBe("Acme_Promotions::dashboard"); + }); +}); diff --git a/packages/aio-commerce-lib-admin-ui-sdk/test/unit/lib/permission-client.test.ts b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/lib/permission-client.test.ts new file mode 100644 index 000000000..bdde3aae9 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/lib/permission-client.test.ts @@ -0,0 +1,330 @@ +/* + * 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 { afterEach, describe, expect, it, vi } from "vitest"; + +import { + AdminUiSdkPermissionDeniedError, + AdminUiSdkPermissionError, +} from "#errors"; +import { getAdminUiSdkPermissionClient } from "#lib/permission-client"; +import { BASE_URL, makeHttpClient } from "#test/fixtures/http-client"; + +const CHECK_URL = `${BASE_URL}/rest/all/V1/adminuisdk/permission/check`; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("client.check — happy path", () => { + it("posts { resource } and returns true when server responds { allowed: true }", async () => { + let capturedBody: unknown; + let capturedUrl = ""; + + const fetchMock = vi.fn(async (input: Request) => { + capturedUrl = input.url; + capturedBody = await input.clone().json(); + return Response.json({ allowed: true }); + }); + + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + }); + const result = await client.check("Acme_Promotions::dashboard"); + + expect(result).toBe(true); + expect(capturedUrl).toBe(CHECK_URL); + expect(capturedBody).toEqual({ resource: "Acme_Promotions::dashboard" }); + }); + + it("returns false when server responds { allowed: false }", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: false })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + }); + + expect(await client.check("Acme_Promotions::dashboard")).toBe(false); + }); +}); + +describe("client.check — TTL cache", () => { + it("does not hit the network twice within TTL", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: true })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 60_000, + }); + + await client.check("Acme_Promotions::dashboard"); + await client.check("Acme_Promotions::dashboard"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("hits the network again after TTL expires", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: true })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 1, + }); + + await client.check("Acme_Promotions::dashboard"); + await new Promise((resolve) => setTimeout(resolve, 10)); + await client.check("Acme_Promotions::dashboard"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("deduplicates concurrent in-flight requests", async () => { + const fetchMock = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return Response.json({ allowed: true }); + }); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 60_000, + }); + + const [a, b] = await Promise.all([ + client.check("Acme_Promotions::dashboard"), + client.check("Acme_Promotions::dashboard"), + ]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(a).toBe(true); + expect(b).toBe(true); + }); + + it("invalidate(resource) clears one cache entry", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: true })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 60_000, + }); + + await client.check("Acme_Promotions::dashboard"); + client.invalidate("Acme_Promotions::dashboard"); + await client.check("Acme_Promotions::dashboard"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("invalidate() with no argument clears all entries", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: true })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 60_000, + }); + + await client.check("Acme_Promotions::dashboard"); + await client.check("Acme_Promotions::edit"); + client.invalidate(); + await client.check("Acme_Promotions::dashboard"); + await client.check("Acme_Promotions::edit"); + + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it("cacheTtlMs: 0 disables caching", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: true })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + }); + + await client.check("Acme_Promotions::dashboard"); + await client.check("Acme_Promotions::dashboard"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not cache an in-flight response after invalidation", async () => { + // biome-ignore lint/suspicious/noEmptyBlockStatements: placeholder reassigned inside the Promise constructor + let resolveFirstResponse: (response: Response) => void = () => {}; + const firstResponse = new Promise((resolve) => { + resolveFirstResponse = resolve; + }); + const fetchMock = vi + .fn() + .mockReturnValueOnce(firstResponse) + .mockResolvedValueOnce(Response.json({ allowed: false })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 60_000, + }); + + const firstCheck = client.check("Acme_Promotions::dashboard"); + client.invalidate("Acme_Promotions::dashboard"); + resolveFirstResponse(Response.json({ allowed: true })); + + expect(await firstCheck).toBe(true); + expect(await client.check("Acme_Promotions::dashboard")).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("client.check — fail-closed (denyOnError: true)", () => { + it("returns false on 5xx", async () => { + const fetchMock = vi.fn(async () => + Response.json({ error: "Internal Server Error" }, { status: 500 }), + ); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + }); + + expect(await client.check("Acme_Promotions::dashboard")).toBe(false); + }); + + it("returns false on network error", async () => { + const fetchMock = vi.fn(() => { + throw new TypeError("Network error"); + }); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + }); + + expect(await client.check("Acme_Promotions::dashboard")).toBe(false); + }); + + it("returns false on schema mismatch", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: "yes" })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + }); + + expect(await client.check("Acme_Promotions::dashboard")).toBe(false); + }); + + it("does not cache fail-closed results", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({}, { status: 500 })) + .mockResolvedValueOnce(Response.json({ allowed: true })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + }); + + expect(await client.check("Acme_Promotions::dashboard")).toBe(false); + expect(await client.check("Acme_Promotions::dashboard")).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("throws AdminUiSdkPermissionError on 401 even with denyOnError: true", async () => { + const fetchMock = vi.fn(async () => + Response.json({ message: "Unauthorized" }, { status: 401 }), + ); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + }); + + await expect( + client.check("Acme_Promotions::dashboard"), + ).rejects.toBeInstanceOf(AdminUiSdkPermissionError); + }); +}); + +describe("client.check — denyOnError: false", () => { + it("throws AdminUiSdkPermissionError on 5xx", async () => { + const fetchMock = vi.fn(async () => Response.json({}, { status: 500 })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + denyOnError: false, + }); + + await expect( + client.check("Acme_Promotions::dashboard"), + ).rejects.toBeInstanceOf(AdminUiSdkPermissionError); + }); +}); + +describe("client.require", () => { + it("resolves when allowed", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: true })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + }); + + await expect( + client.require("Acme_Promotions::dashboard"), + ).resolves.toBeUndefined(); + }); + + it("throws AdminUiSdkPermissionDeniedError when denied", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: false })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + }); + + await expect( + client.require("Acme_Promotions::dashboard"), + ).rejects.toBeInstanceOf(AdminUiSdkPermissionDeniedError); + }); + + it("throws AdminUiSdkPermissionError on 5xx with default denyOnError", async () => { + const fetchMock = vi.fn(async () => Response.json({}, { status: 500 })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + }); + + await expect( + client.require("Acme_Promotions::dashboard"), + ).rejects.toBeInstanceOf(AdminUiSdkPermissionError); + }); + + it("throws AdminUiSdkPermissionError on network error with default denyOnError", async () => { + const fetchMock = vi.fn(() => { + throw new TypeError("Network error"); + }); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + }); + + await expect( + client.require("Acme_Promotions::dashboard"), + ).rejects.toBeInstanceOf(AdminUiSdkPermissionError); + }); + + it("throws AdminUiSdkPermissionError on schema mismatch with default denyOnError", async () => { + const fetchMock = vi.fn(async () => Response.json({ allowed: "yes" })); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + }); + + await expect( + client.require("Acme_Promotions::dashboard"), + ).rejects.toBeInstanceOf(AdminUiSdkPermissionError); + }); + + it("throws AdminUiSdkPermissionError on 401", async () => { + const fetchMock = vi.fn(async () => + Response.json({ message: "Unauthorized" }, { status: 401 }), + ); + const client = getAdminUiSdkPermissionClient({ + httpClient: makeHttpClient(fetchMock as typeof fetch), + cacheTtlMs: 0, + }); + + await expect( + client.require("Acme_Promotions::dashboard"), + ).rejects.toBeInstanceOf(AdminUiSdkPermissionError); + }); +}); diff --git a/packages/aio-commerce-lib-admin-ui-sdk/tsconfig.json b/packages/aio-commerce-lib-admin-ui-sdk/tsconfig.json new file mode 100644 index 000000000..82d00f62c --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@aio-commerce-sdk/config-typescript/tsconfig.base.json" +} diff --git a/packages/aio-commerce-lib-admin-ui-sdk/tsdown.config.ts b/packages/aio-commerce-lib-admin-ui-sdk/tsdown.config.ts new file mode 100644 index 000000000..51386e73c --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/tsdown.config.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { baseConfig } from "@aio-commerce-sdk/config-tsdown/tsdown.config.base"; +import { mergeConfig } from "tsdown"; + +export default mergeConfig(baseConfig, { + entry: ["./source/index.ts"], +}); diff --git a/packages/aio-commerce-lib-admin-ui-sdk/vitest.config.ts b/packages/aio-commerce-lib-admin-ui-sdk/vitest.config.ts new file mode 100644 index 000000000..26b199c3d --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/vitest.config.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 { baseConfig } from "@aio-commerce-sdk/config-vitest/vitest.config.base"; +import { defineConfig, mergeConfig } from "vitest/config"; + +const BARREL_FILES = ["./source/index.ts"]; + +export default mergeConfig( + baseConfig, + defineConfig({ + plugins: [], + test: { + passWithNoTests: true, + coverage: { + exclude: [...BARREL_FILES, "./source/**/types.ts"], + }, + }, + }), +); diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index c58df3eac..7479fcac9 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -479,6 +479,10 @@ adminUiSdk: { sortOrder: 1, isSection: false, sandbox: "allow-modals", + aclResource: { + id: "Acme_Promotions::dashboard", + title: "Promotions Dashboard", + }, }, ], @@ -597,6 +601,26 @@ adminUiSdk: { - **sortOrder**: Optional number - **isSection**: Optional boolean - **sandbox**: Optional; space-separated combination of `"allow-downloads"`, `"allow-modals"`, `"allow-popups"` +- **aclResource**: Optional Admin UI SDK ACL resource; both `id` and `title` are required. `id` must follow the `Vendor_Module::resource_name` format. Optional: `parent` (same format) sets the ACL hierarchy parent; `sortOrder` controls display order. Commerce uses this resource to decide whether the menu item is visible to the current admin user. The same resource can also gate a SPA route or runtime action in the app itself — see the [`@adobe/aio-commerce-lib-admin-ui-sdk` permission helper](../../aio-commerce-lib-admin-ui-sdk/docs/usage.md#permission-checking). + +Minimal menu item ACL example: + +```javascript +adminUiSdk: { + registration: { + menuItems: [ + { + id: "promotions/dashboard", + title: "Promotions", + aclResource: { + id: "Acme_Promotions::dashboard", + title: "Promotions Dashboard", + }, + }, + ], + }, +} +``` ##### Order Extension Points: diff --git a/packages/aio-commerce-lib-app/package.json b/packages/aio-commerce-lib-app/package.json index 580fb99c5..6fde61f81 100644 --- a/packages/aio-commerce-lib-app/package.json +++ b/packages/aio-commerce-lib-app/package.json @@ -106,6 +106,7 @@ "test:ui": "vitest --ui --coverage" }, "dependencies": { + "@adobe/aio-commerce-lib-admin-ui-sdk": "workspace:*", "@adobe/aio-commerce-lib-api": "workspace:*", "@adobe/aio-commerce-lib-auth": "workspace:*", "@adobe/aio-commerce-lib-config": "workspace:*", diff --git a/packages/aio-commerce-lib-app/source/config/index.ts b/packages/aio-commerce-lib-app/source/config/index.ts index 9c441c123..f2e6f4e2a 100644 --- a/packages/aio-commerce-lib-app/source/config/index.ts +++ b/packages/aio-commerce-lib-app/source/config/index.ts @@ -50,6 +50,7 @@ export { hasMetadata } from "./schema/metadata"; export { hasWebhooks } from "./schema/webhooks"; export type { + AclResource, AdminUiSdkConfiguration, AdminUiSdkRegistration, BannerNotification, diff --git a/packages/aio-commerce-lib-app/source/config/schema/admin-ui-sdk.ts b/packages/aio-commerce-lib-app/source/config/schema/admin-ui-sdk.ts index d1311b3b2..f753023c3 100644 --- a/packages/aio-commerce-lib-app/source/config/schema/admin-ui-sdk.ts +++ b/packages/aio-commerce-lib-app/source/config/schema/admin-ui-sdk.ts @@ -235,13 +235,39 @@ const BannerNotificationSchema = v.object({ orderViewButtons: v.optional(v.array(OrderViewButtonBannerSchema)), }); +const ACL_RESOURCE_ID_REGEX = + /^[A-Z][A-Za-z0-9]+_[A-Z][A-Za-z0-9]+::[A-Za-z_0-9]+$/; +const ACL_RESOURCE_ID_REGEX_MESSAGE = + "must follow the Vendor_Module::resource_name format"; + +function aclResourceIdSchema(label: string) { + return v.pipe( + nonEmptyStringValueSchema(label), + v.regex(ACL_RESOURCE_ID_REGEX, `${label} ${ACL_RESOURCE_ID_REGEX_MESSAGE}`), + ); +} + +const AclResourceSchema = v.object({ + id: aclResourceIdSchema("ACL resource ID"), + title: nonEmptyStringValueSchema("ACL resource title"), + parent: v.optional(aclResourceIdSchema("ACL resource parent")), + sortOrder: v.optional(positiveNumberValueSchema("ACL resource sortOrder")), +}); + const MenuItemSchema = v.object({ - id: nonEmptyStringValueSchema("menu item ID"), + id: v.pipe( + nonEmptyStringValueSchema("menu item ID"), + v.regex( + /^[A-Za-z0-9/:_]+$/, + "menu item ID must contain only alphanumeric characters, forward slashes (/), colons (:), and underscores (_)", + ), + ), title: v.optional(nonEmptyStringValueSchema("menu item title")), parent: v.optional(nonEmptyStringValueSchema("menu item parent")), sortOrder: v.optional(v.number()), isSection: v.optional(booleanValueSchema("isSection")), sandbox: v.optional(SandboxSchema), + aclResource: v.optional(AclResourceSchema), }); /** @@ -326,6 +352,12 @@ export type BannerNotification = v.InferInput; */ export type MenuItem = v.InferInput; +/** + * An ACL resource registration entry for Admin UI SDK menu items. + * @experimental + */ +export type AclResource = v.InferInput; + /** * Check if config has Admin UI SDK registration configuration. * @experimental diff --git a/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/branch.ts b/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/branch.ts index 063135b47..805981167 100644 --- a/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/branch.ts +++ b/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/branch.ts @@ -16,7 +16,7 @@ import { defineLeafStep, } from "#management/installation/workflow/step"; -import { registerExtension, uninstallExtension } from "./helpers"; +import { registerExtension, unregisterExtension } from "./helpers"; import { createAdminUiSdkStepContext } from "./utils"; import type { InferStepOutput } from "#management/installation/workflow/step"; @@ -40,7 +40,7 @@ const registerExtensionStep = defineLeafStep({ registerExtension(context), uninstall: (_: AdminUiSdkConfig, context: AdminUiSdkExecutionContext) => - uninstallExtension(context), + unregisterExtension(context), }); /** The output data of the register extension step (auto-inferred). */ diff --git a/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/helpers.ts b/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/helpers.ts index b15c3d27f..18d18901d 100644 --- a/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/helpers.ts +++ b/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/helpers.ts @@ -16,34 +16,24 @@ import { throwHttpError } from "../utils/http-error"; import type { AdminUiSdkExecutionContext } from "./utils"; -/** The response shape returned by POST /V1/adminuisdk/extension. */ -export type RegisterExtensionResponse = { - extensionId: string; -}; - /** * Registers the extension with Commerce via POST /V1/adminuisdk/extension. * - * @param context - The execution context providing the Commerce HTTP client and logger. + * @param context - The execution context providing the Admin UI SDK client and logger. * @returns The response from the Commerce API. */ export async function registerExtension(context: AdminUiSdkExecutionContext) { - const { commerceClient, appData, logger } = context; + const { adminUiSdkClient, appData, logger } = context; logger.info(`Registering Admin UI SDK extension: ${appData.projectName}`); - const response = await commerceClient - .post("adminuisdk/extension", { - json: { - extension: { - extensionName: process.env.__OW_NAMESPACE, - extensionTitle: appData.projectTitle, - extensionUrl: `https://${process.env.__OW_NAMESPACE}.adobeio-static.net/index.html`, - extensionWorkspace: appData.workspaceName, - }, - }, + await adminUiSdkClient + .registerExtension({ + extensionName: process.env.__OW_NAMESPACE ?? "", + extensionTitle: appData.projectTitle, + extensionUrl: `https://${process.env.__OW_NAMESPACE}.adobeio-static.net/index.html`, + extensionWorkspace: appData.workspaceName, }) - .json() .catch((error: unknown) => throwHttpError( logger, @@ -52,32 +42,30 @@ export async function registerExtension(context: AdminUiSdkExecutionContext) { ), ); - logger.info( - `Admin UI SDK extension registered successfully: ${response.extensionId}`, - ); - - return response; + logger.info("Admin UI SDK extension registered successfully."); } /** * Unregisters the extension from Commerce via DELETE /V1/adminuisdk/extension/:workspace_name/:extension_name. * Best-effort: errors are logged as warnings and do not stop the uninstall workflow. * - * @param context - The execution context providing the Commerce HTTP client and logger. + * @param context - The execution context providing the Admin UI SDK client and logger. */ -export async function uninstallExtension( +export async function unregisterExtension( context: AdminUiSdkExecutionContext, ): Promise { - const { commerceClient, appData, logger } = context; - const extensionName = process.env.__OW_NAMESPACE; - const endpoint = `adminuisdk/extension/${appData.workspaceName}/${extensionName}`; + const { adminUiSdkClient, appData, logger } = context; + const extensionName = process.env.__OW_NAMESPACE ?? ""; logger.info( `Unregistering Admin UI SDK extension "${extensionName}" from workspace "${appData.workspaceName}"...`, ); try { - await commerceClient.delete(endpoint); + await adminUiSdkClient.unregisterExtension({ + workspaceName: appData.workspaceName, + extensionName, + }); logger.info( `Admin UI SDK extension "${extensionName}" unregistered successfully.`, ); diff --git a/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/utils.ts b/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/utils.ts index cb1c98749..7c78f73e8 100644 --- a/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/utils.ts +++ b/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/utils.ts @@ -11,10 +11,13 @@ */ import { - AdobeCommerceHttpClient, - resolveCommerceHttpClientParams, -} from "@adobe/aio-commerce-lib-api"; + createCustomAdminUiSdkApiClient, + registerExtension, + unregisterExtension, +} from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; +import { resolveCommerceHttpClientParams } from "@adobe/aio-commerce-lib-api"; +import type { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; import type { CommerceAppConfigOutputModel } from "#config/schema/app"; import type { ExecutionContext, @@ -27,32 +30,45 @@ export type AdminUiSdkConfig = CommerceAppConfigOutputModel & { adminUiSdk: NonNullable; }; -/** Context shared across Admin UI SDK steps. */ +function createAdminUiSdkClient(params: RuntimeActionParams) { + const commerceClientParams = resolveCommerceHttpClientParams(params, { + tryForwardAuthProvider: true, + }); + + return createCustomAdminUiSdkApiClient(commerceClientParams, { + registerExtension, + unregisterExtension, + }); +} + +/** A custom Admin UI SDK API client with only the operations used during installation. */ +export type CustomAdminUiSdkApiClient = ReturnType< + typeof createAdminUiSdkClient +>; + +/** Context shared across Admin UI SDK installation steps. */ export interface AdminUiSdkStepContext extends Record { - get commerceClient(): AdobeCommerceHttpClient; + get adminUiSdkClient(): CustomAdminUiSdkApiClient; } /** The execution context for Admin UI SDK leaf steps. */ export type AdminUiSdkExecutionContext = ExecutionContext; -/** Creates the Admin UI SDK step context with a lazy-initialized Commerce HTTP client. */ +/** Creates the Admin UI SDK step context with a lazy-initialized API client. */ export const createAdminUiSdkStepContext: StepContextFactory< AdminUiSdkStepContext > = (installation: InstallationContext) => { const { params } = installation; - let commerceClient: AdobeCommerceHttpClient | null = null; + let adminUiSdkClient: CustomAdminUiSdkApiClient | null = null; return { - get commerceClient() { - if (commerceClient === null) { - const clientParams = resolveCommerceHttpClientParams(params, { - tryForwardAuthProvider: true, - }); - commerceClient = new AdobeCommerceHttpClient(clientParams); + get adminUiSdkClient() { + if (adminUiSdkClient === null) { + adminUiSdkClient = createAdminUiSdkClient(params); } - return commerceClient; + return adminUiSdkClient; }, }; }; diff --git a/packages/aio-commerce-lib-app/test/fixtures/admin-ui-sdk.ts b/packages/aio-commerce-lib-app/test/fixtures/admin-ui-sdk.ts index e95ded6dc..8ede31fbe 100644 --- a/packages/aio-commerce-lib-app/test/fixtures/admin-ui-sdk.ts +++ b/packages/aio-commerce-lib-app/test/fixtures/admin-ui-sdk.ts @@ -16,27 +16,26 @@ import { createMockInstallationContext } from "#test/fixtures/installation"; import type { AdminUiSdkExecutionContext } from "#management/installation/admin-ui-sdk/utils"; -/** Creates a mock AdminUiSdkExecutionContext with Commerce client methods. */ +/** Creates a mock AdminUiSdkExecutionContext with Admin UI SDK client methods. */ export function createMockAdminUiSdkContext(overrides?: { - postImpl?: () => Promise; - deleteImpl?: () => Promise; + registerExtensionImpl?: () => Promise; + unregisterExtensionImpl?: () => Promise; }): AdminUiSdkExecutionContext { const mockInstallation = createMockInstallationContext(); - const jsonFn = vi - .fn() - .mockImplementation( - overrides?.postImpl ?? - (() => Promise.resolve({ extensionId: "ext-123" })), - ); - const postFn = vi.fn().mockReturnValue({ json: jsonFn }); return { ...mockInstallation, - commerceClient: { - post: postFn, - delete: vi + adminUiSdkClient: { + registerExtension: vi .fn() - .mockImplementation(overrides?.deleteImpl ?? (() => Promise.resolve())), - } as unknown as AdminUiSdkExecutionContext["commerceClient"], + .mockImplementation( + overrides?.registerExtensionImpl ?? (() => Promise.resolve()), + ), + unregisterExtension: vi + .fn() + .mockImplementation( + overrides?.unregisterExtensionImpl ?? (() => Promise.resolve()), + ), + } as unknown as AdminUiSdkExecutionContext["adminUiSdkClient"], }; } diff --git a/packages/aio-commerce-lib-app/test/fixtures/config.ts b/packages/aio-commerce-lib-app/test/fixtures/config.ts index a399ad8ad..9d8456fdd 100644 --- a/packages/aio-commerce-lib-app/test/fixtures/config.ts +++ b/packages/aio-commerce-lib-app/test/fixtures/config.ts @@ -125,7 +125,7 @@ export const configWithAdminUiSdk = { registration: { menuItems: [ { - id: "test-app::menu", + id: "test_app::menu", title: "Test App", sortOrder: 1, isSection: false, @@ -228,12 +228,16 @@ export const configWithFullAdminUiSdk = { registration: { menuItems: [ { - id: "my-app::first", + id: "my_app::first", title: "App on App Builder", parent: "my-app::apps", sortOrder: 1, isSection: false, sandbox: "allow-modals", + aclResource: { + id: "Acme_Promotions::dashboard", + title: "Promotions Dashboard", + }, }, ], order: { diff --git a/packages/aio-commerce-lib-app/test/integration/commands/generate/actions.test.ts b/packages/aio-commerce-lib-app/test/integration/commands/generate/actions.test.ts index 3fe7ea4f8..c3b8edc0d 100644 --- a/packages/aio-commerce-lib-app/test/integration/commands/generate/actions.test.ts +++ b/packages/aio-commerce-lib-app/test/integration/commands/generate/actions.test.ts @@ -145,7 +145,7 @@ describe("commands/generate/actions", () => { const content = await readFile(registrationPath, "utf-8"); expect(content).toContain("registrationRuntimeAction"); - expect(content).toContain("my-app::first"); + expect(content).toContain("my_app::first"); }, ); }); diff --git a/packages/aio-commerce-lib-app/test/integration/commands/hooks/pre-app-build.test.ts b/packages/aio-commerce-lib-app/test/integration/commands/hooks/pre-app-build.test.ts index f55896116..f4bc5feda 100644 --- a/packages/aio-commerce-lib-app/test/integration/commands/hooks/pre-app-build.test.ts +++ b/packages/aio-commerce-lib-app/test/integration/commands/hooks/pre-app-build.test.ts @@ -128,7 +128,7 @@ describe("commands/hooks/pre-app-build", () => { const content = await readFile(registrationPath, "utf-8"); expect(content).toContain("registrationRuntimeAction"); - expect(content).toContain("my-app::first"); + expect(content).toContain("my_app::first"); }, ); }); diff --git a/packages/aio-commerce-lib-app/test/unit/commands/generate/actions/lib.test.ts b/packages/aio-commerce-lib-app/test/unit/commands/generate/actions/lib.test.ts index f6375e7eb..39d1b5efe 100644 --- a/packages/aio-commerce-lib-app/test/unit/commands/generate/actions/lib.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/commands/generate/actions/lib.test.ts @@ -247,7 +247,7 @@ describe("generateRegistrationActionFile", () => { expect(contentStr).toContain( "export const main = registrationRuntimeAction({ registration })", ); - expect(contentStr).toContain('"my-app::first"'); + expect(contentStr).toContain('"my_app::first"'); expect(contentStr).toContain("selectionLimit: 1"); expect(contentStr).toContain("productSelectLimit: 1"); expect(contentStr).toContain("customerSelectLimit: 1"); diff --git a/packages/aio-commerce-lib-app/test/unit/config/schema/admin-ui-sdk.test.ts b/packages/aio-commerce-lib-app/test/unit/config/schema/admin-ui-sdk.test.ts index 6a4dba9b4..abf4b265e 100644 --- a/packages/aio-commerce-lib-app/test/unit/config/schema/admin-ui-sdk.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/config/schema/admin-ui-sdk.test.ts @@ -65,6 +65,44 @@ describe("AdminUiSdkSchema", () => { expect(result.success).toBe(true); }); + test("menu item with aclResource id and title", () => { + const result = v.safeParse(AdminUiSdkSchema, { + registration: { + menuItems: [ + { + id: "promotions/dashboard", + aclResource: { + id: "Acme_Promotions::dashboard", + title: "Promotions Dashboard", + }, + }, + ], + }, + }); + + expect(result.success).toBe(true); + }); + + test("menu item with aclResource parent and sortOrder", () => { + const result = v.safeParse(AdminUiSdkSchema, { + registration: { + menuItems: [ + { + id: "promotions/campaigns", + aclResource: { + id: "Acme_Promotions::campaigns", + title: "Campaigns", + parent: "Acme_Promotions::dashboard", + sortOrder: 20, + }, + }, + ], + }, + }); + + expect(result.success).toBe(true); + }); + test("order mass action with selectionLimit", () => { const result = v.safeParse(AdminUiSdkSchema, { registration: { @@ -237,6 +275,21 @@ describe("AdminUiSdkSchema", () => { }); describe("invalid cases", () => { + test("menu item with aclResource missing title — parse fails", () => { + const result = v.safeParse(AdminUiSdkSchema, { + registration: { + menuItems: [ + { + id: "promotions/dashboard", + aclResource: { id: "Acme_Promotions::dashboard" }, + }, + ], + }, + }); + + expect(result.success).toBe(false); + }); + test("mass action missing required actionId — parse fails", () => { const result = v.safeParse(AdminUiSdkSchema, { registration: { @@ -348,6 +401,107 @@ describe("AdminUiSdkSchema", () => { expect(result.success).toBe(false); }); + test("menu item id with invalid characters — parse fails", () => { + const result = v.safeParse(AdminUiSdkSchema, { + registration: { + menuItems: [{ id: "promotions dashboard!", title: "Item" }], + }, + }); + expect(result.success).toBe(false); + }); + + test("menu item with aclResource id in invalid format — parse fails", () => { + const result = v.safeParse(AdminUiSdkSchema, { + registration: { + menuItems: [ + { + id: "promotions/dashboard", + aclResource: { id: "promotions/dashboard", title: "Dashboard" }, + }, + ], + }, + }); + expect(result.success).toBe(false); + }); + + test("menu item with aclResource id with lowercase vendor — parse fails", () => { + const result = v.safeParse(AdminUiSdkSchema, { + registration: { + menuItems: [ + { + id: "promotions/dashboard", + aclResource: { + id: "acme_Promotions::dashboard", + title: "Dashboard", + }, + }, + ], + }, + }); + expect(result.success).toBe(false); + }); + + test("menu item with aclResource id with lowercase module — parse fails", () => { + const result = v.safeParse(AdminUiSdkSchema, { + registration: { + menuItems: [ + { + id: "promotions/dashboard", + aclResource: { + id: "Acme_promotions::dashboard", + title: "Dashboard", + }, + }, + ], + }, + }); + expect(result.success).toBe(false); + }); + + test("menu item with aclResource empty id — parse fails", () => { + const result = v.safeParse(AdminUiSdkSchema, { + registration: { + menuItems: [{ id: "promotions/dashboard", aclResource: { id: "" } }], + }, + }); + + expect(result.success).toBe(false); + }); + + test("menu item with aclResource missing id — parse fails", () => { + const result = v.safeParse(AdminUiSdkSchema, { + registration: { + menuItems: [ + { + id: "promotions/dashboard", + aclResource: { title: "Only title" }, + }, + ], + }, + }); + + expect(result.success).toBe(false); + }); + + test("menu item with aclResource parent in invalid format — parse fails", () => { + const result = v.safeParse(AdminUiSdkSchema, { + registration: { + menuItems: [ + { + id: "promotions/dashboard", + aclResource: { + id: "Acme_Promotions::dashboard", + title: "Dashboard", + parent: "invalid-parent/format", + }, + }, + ], + }, + }); + + expect(result.success).toBe(false); + }); + test("custom fee missing required value — parse fails", () => { const result = v.safeParse(AdminUiSdkSchema, { registration: { @@ -450,3 +604,12 @@ describe("AdminUiSdkSchema", () => { }); }); }); + +describe("AclResource type export", () => { + test("AclResource is exported from the schema file", () => { + // Compile-time check: this import would fail TypeScript if AclResource is not exported. + type _Check = import("#config/schema/admin-ui-sdk").AclResource; + // Minimal runtime guard so the test is not empty. + expect(true).toBe(true); + }); +}); diff --git a/packages/aio-commerce-lib-app/test/unit/management/installation/admin-ui-sdk/branch.test.ts b/packages/aio-commerce-lib-app/test/unit/management/installation/admin-ui-sdk/branch.test.ts index 5acf42b7c..9a7be4a41 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/installation/admin-ui-sdk/branch.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/installation/admin-ui-sdk/branch.test.ts @@ -82,19 +82,23 @@ describe("admin-ui-sdk installation module", () => { expect(registerExtensionStep.meta.uninstall).toBeDefined(); }); - test("should call DELETE for the extension using workspaceName and __OW_NAMESPACE", async () => { + test("should call unregisterExtension with workspaceName and __OW_NAMESPACE", async () => { const context = createMockAdminUiSdkContext(); await registerExtensionStep.uninstall?.(configWithAdminUiSdk, context); - expect(context.commerceClient.delete).toHaveBeenCalledWith( - `adminuisdk/extension/${context.appData.workspaceName}/test-namespace`, + expect(context.adminUiSdkClient.unregisterExtension).toHaveBeenCalledWith( + { + workspaceName: context.appData.workspaceName, + extensionName: "test-namespace", + }, ); }); - test("should not throw when the DELETE call fails (best-effort)", async () => { + test("should not throw when the uninstall call fails (best-effort)", async () => { const context = createMockAdminUiSdkContext({ - deleteImpl: () => Promise.reject(new Error("Commerce API error")), + unregisterExtensionImpl: () => + Promise.reject(new Error("Commerce API error")), }); await expect( @@ -102,11 +106,12 @@ describe("admin-ui-sdk installation module", () => { ).resolves.toBeUndefined(); }); - test("should log a warning when the DELETE call fails", async () => { + test("should log a warning when the uninstall call fails", async () => { const logger = createMockLogger(); const context = { ...createMockAdminUiSdkContext({ - deleteImpl: () => Promise.reject(new Error("Commerce API error")), + unregisterExtensionImpl: () => + Promise.reject(new Error("Commerce API error")), }), logger, }; diff --git a/packages/aio-commerce-lib-app/test/unit/management/installation/admin-ui-sdk/helpers.test.ts b/packages/aio-commerce-lib-app/test/unit/management/installation/admin-ui-sdk/helpers.test.ts index 628298bec..7aaf0597e 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/installation/admin-ui-sdk/helpers.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/installation/admin-ui-sdk/helpers.test.ts @@ -14,7 +14,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { registerExtension, - uninstallExtension, + unregisterExtension, } from "#management/installation/admin-ui-sdk/helpers"; import { createMockAdminUiSdkContext } from "#test/fixtures/admin-ui-sdk"; import { makeHttpError } from "#test/fixtures/http-error"; @@ -32,14 +32,36 @@ describe("registerExtension", () => { vi.unstubAllEnvs(); }); - test("throws enriched error when POST fails", async () => { + test("logs success when registerExtension call resolves without a response body", async () => { + const logger = createMockLogger(); + const context = { + ...createMockAdminUiSdkContext({ + registerExtensionImpl: () => Promise.resolve(), + }), + logger, + }; + + await expect(registerExtension(context)).resolves.toBeUndefined(); + + expect(context.adminUiSdkClient.registerExtension).toHaveBeenCalledWith({ + extensionName: "test-ns", + extensionTitle: context.appData.projectTitle, + extensionUrl: "https://test-ns.adobeio-static.net/index.html", + extensionWorkspace: context.appData.workspaceName, + }); + expect(logger.info).toHaveBeenCalledWith( + "Admin UI SDK extension registered successfully.", + ); + }); + + test("throws enriched error when registerExtension call fails", async () => { const httpError = makeHttpError( 403, "Forbidden", JSON.stringify({ message: "Insufficient permissions" }), ); const context = createMockAdminUiSdkContext({ - postImpl: () => Promise.reject(httpError), + registerExtensionImpl: () => Promise.reject(httpError), }); await expect(registerExtension(context)).rejects.toThrow( @@ -56,7 +78,7 @@ describe("registerExtension", () => { ); const context = { ...createMockAdminUiSdkContext({ - postImpl: () => Promise.reject(httpError), + registerExtensionImpl: () => Promise.reject(httpError), }), logger, }; @@ -70,7 +92,7 @@ describe("registerExtension", () => { }); }); -describe("uninstallExtension", () => { +describe("unregisterExtension", () => { beforeEach(() => { vi.stubEnv("__OW_NAMESPACE", "test-ns"); }); @@ -79,7 +101,7 @@ describe("uninstallExtension", () => { vi.unstubAllEnvs(); }); - test("warns with enriched error message when DELETE fails", async () => { + test("warns with enriched error message when unregisterExtension call fails", async () => { const logger = createMockLogger(); const httpError = makeHttpError( 500, @@ -88,12 +110,12 @@ describe("uninstallExtension", () => { ); const context = { ...createMockAdminUiSdkContext({ - deleteImpl: () => Promise.reject(httpError), + unregisterExtensionImpl: () => Promise.reject(httpError), }), logger, }; - await uninstallExtension(context); + await unregisterExtension(context); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("test-ns"), diff --git a/packages/aio-commerce-sdk/package.json b/packages/aio-commerce-sdk/package.json index 166b7096b..0e5ea1736 100644 --- a/packages/aio-commerce-sdk/package.json +++ b/packages/aio-commerce-sdk/package.json @@ -76,6 +76,16 @@ "default": "./dist/cjs/webhooks/*.cjs" } }, + "./admin-ui-sdk/*": { + "import": { + "types": "./dist/es/admin-ui-sdk/*.d.mts", + "default": "./dist/es/admin-ui-sdk/*.mjs" + }, + "require": { + "types": "./dist/cjs/admin-ui-sdk/*.d.cts", + "default": "./dist/cjs/admin-ui-sdk/*.cjs" + } + }, "./*": { "import": { "types": "./dist/es/*.d.mts", @@ -113,6 +123,7 @@ "@adobe/aio-commerce-lib-auth": "workspace:*", "@adobe/aio-commerce-lib-core": "workspace:*", "@adobe/aio-commerce-lib-api": "workspace:*", + "@adobe/aio-commerce-lib-admin-ui-sdk": "workspace:*", "@adobe/aio-commerce-lib-events": "workspace:*", "@adobe/aio-commerce-lib-webhooks": "workspace:*", "type-fest": "catalog:" @@ -122,5 +133,80 @@ "@aio-commerce-sdk/config-typescript": "workspace:*", "typescript": "catalog:" }, - "sideEffects": false + "sideEffects": false, + "publishConfig": { + "exports": { + "./api": { + "import": { + "types": "./dist/es/api/index.d.mts", + "default": "./dist/es/api/index.mjs" + }, + "require": { + "types": "./dist/cjs/api/index.d.cts", + "default": "./dist/cjs/api/index.cjs" + } + }, + "./api/*": { + "import": { + "types": "./dist/es/api/*.d.mts", + "default": "./dist/es/api/*.mjs" + }, + "require": { + "types": "./dist/cjs/api/*.d.cts", + "default": "./dist/cjs/api/*.cjs" + } + }, + "./events/*": { + "import": { + "types": "./dist/es/events/*.d.mts", + "default": "./dist/es/events/*.mjs" + }, + "require": { + "types": "./dist/cjs/events/*.d.cts", + "default": "./dist/cjs/events/*.cjs" + } + }, + "./core/*": { + "import": { + "types": "./dist/es/core/*.d.mts", + "default": "./dist/es/core/*.mjs" + }, + "require": { + "types": "./dist/cjs/core/*.d.cts", + "default": "./dist/cjs/core/*.cjs" + } + }, + "./webhooks/*": { + "import": { + "types": "./dist/es/webhooks/*.d.mts", + "default": "./dist/es/webhooks/*.mjs" + }, + "require": { + "types": "./dist/cjs/webhooks/*.d.cts", + "default": "./dist/cjs/webhooks/*.cjs" + } + }, + "./admin-ui-sdk/*": { + "import": { + "types": "./dist/es/admin-ui-sdk/*.d.mts", + "default": "./dist/es/admin-ui-sdk/*.mjs" + }, + "require": { + "types": "./dist/cjs/admin-ui-sdk/*.d.cts", + "default": "./dist/cjs/admin-ui-sdk/*.cjs" + } + }, + "./*": { + "import": { + "types": "./dist/es/*.d.mts", + "default": "./dist/es/*.mjs" + }, + "require": { + "types": "./dist/cjs/*.d.cts", + "default": "./dist/cjs/*.cjs" + } + }, + "./package.json": "./package.json" + } + } } diff --git a/packages/aio-commerce-sdk/source/admin-ui-sdk/api.ts b/packages/aio-commerce-sdk/source/admin-ui-sdk/api.ts new file mode 100644 index 000000000..40e572dd6 --- /dev/null +++ b/packages/aio-commerce-sdk/source/admin-ui-sdk/api.ts @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** biome-ignore-all lint/performance/noBarrelFile: Public API for the admin-ui-sdk/api entrypoint */ + +export * from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21e6b89f8..788d89c8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,46 @@ importers: specifier: 'catalog:' version: 6.0.3 + packages/aio-commerce-lib-admin-ui-sdk: + dependencies: + '@adobe/aio-commerce-lib-api': + specifier: workspace:* + version: link:../aio-commerce-lib-api + '@adobe/aio-commerce-lib-core': + specifier: workspace:* + version: link:../aio-commerce-lib-core + ky: + specifier: 'catalog:' + version: 1.14.3 + valibot: + specifier: 'catalog:' + version: 1.4.0(typescript@6.0.3) + devDependencies: + '@aio-commerce-sdk/common-utils': + specifier: workspace:* + version: link:../../packages-private/common-utils + '@aio-commerce-sdk/config-tsdown': + specifier: workspace:* + version: link:../../configs/tsdown + '@aio-commerce-sdk/config-typescript': + specifier: workspace:* + version: link:../../configs/typescript + '@aio-commerce-sdk/config-vitest': + specifier: workspace:* + version: link:../../configs/vitest + '@aio-commerce-sdk/scripting-utils': + specifier: workspace:* + version: link:../../packages-private/scripting-utils + '@aio-commerce-sdk/scripts': + specifier: workspace:* + version: link:../../scripts + msw: + specifier: 'catalog:' + version: 2.14.6(@types/node@24.12.4)(typescript@6.0.3) + typescript: + specifier: 'catalog:' + version: 6.0.3 + packages/aio-commerce-lib-api: dependencies: '@adobe/aio-commerce-lib-auth': @@ -255,6 +295,9 @@ importers: packages/aio-commerce-lib-app: dependencies: + '@adobe/aio-commerce-lib-admin-ui-sdk': + specifier: workspace:* + version: link:../aio-commerce-lib-admin-ui-sdk '@adobe/aio-commerce-lib-api': specifier: workspace:* version: link:../aio-commerce-lib-api @@ -570,6 +613,9 @@ importers: packages/aio-commerce-sdk: dependencies: + '@adobe/aio-commerce-lib-admin-ui-sdk': + specifier: workspace:* + version: link:../aio-commerce-lib-admin-ui-sdk '@adobe/aio-commerce-lib-api': specifier: workspace:* version: link:../aio-commerce-lib-api @@ -670,6 +716,7 @@ packages: '@adobe/aio-lib-core-networking@5.1.0': resolution: {integrity: sha512-RZgA73yUHhikjIGy7k0DRobPbIXbtuI/0ixrBXOVqj19WTqFFrEuyHtzOuRsBAmu0OvVkf6slQHtDTL9oPLxfg==} engines: {node: '>=18'} + bundledDependencies: [] '@adobe/aio-lib-core-tvm@4.0.3': resolution: {integrity: sha512-zwl4GeU5CryZBozpub4jI9tnGW2ewD6mMLaXX8HhO8DqKzcjnjYr+MbUS0c/QzGd8gTzZWbikm4J7tfpCn3cAA==} @@ -678,6 +725,7 @@ packages: '@adobe/aio-lib-env@3.0.1': resolution: {integrity: sha512-UaLosV8jBowEA2ho4BNmWuHhrNCFbx26kJAr2SAIdEm4lZ/D8av8FUSMOEyAKJ/dfO2HCnLKMy77ie2AU7HI3g==} engines: {node: '>=18'} + bundledDependencies: [] '@adobe/aio-lib-files@4.1.3': resolution: {integrity: sha512-OO0752wrKx57Uvu+OLkUA7UiU6OG3iQj5T0IjTqq1pflnG9wPk4fjjERj++0xv2tf/gtJouZGcUtfAD15p1vrg==}