From 0ef0a94171c5c07f2a4098bed92f52e0a9c2bf9f Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Mon, 4 May 2026 14:12:06 -0500 Subject: [PATCH 01/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions --- .changeset/admin-ui-sdk-permissions.md | 5 + .changeset/menu-item-acl-resource.md | 5 + packages/aio-commerce-lib-api/README.md | 41 +++ packages/aio-commerce-lib-api/docs/usage.md | 64 ++++ packages/aio-commerce-lib-api/package.json | 3 +- packages/aio-commerce-lib-api/source/index.ts | 1 + .../admin-ui-sdk-permissions/client.ts | 159 +++++++++ .../admin-ui-sdk-permissions/errors.ts | 26 ++ .../admin-ui-sdk-permissions/index.ts | 20 ++ .../admin-ui-sdk-permissions/schemas.ts | 23 ++ .../admin-ui-sdk-permissions/types.ts | 39 +++ .../with-permission.ts | 38 +++ .../admin-ui-sdk-permissions/client.test.ts | 305 ++++++++++++++++++ .../admin-ui-sdk-permissions/errors.test.ts | 70 ++++ .../admin-ui-sdk-permissions/schemas.test.ts | 46 +++ .../admin-ui-sdk-permissions/types.test.ts | 75 +++++ .../with-permission.test.ts | 77 +++++ .../aio-commerce-lib-api/vitest.config.ts | 1 + packages/aio-commerce-lib-app/docs/usage.md | 26 ++ .../source/config/schema/admin-ui-sdk.ts | 6 + .../test/fixtures/config.ts | 4 + .../unit/config/schema/admin-ui-sdk.test.ts | 58 ++++ pnpm-lock.yaml | 3 + 23 files changed, 1094 insertions(+), 1 deletion(-) create mode 100644 .changeset/admin-ui-sdk-permissions.md create mode 100644 .changeset/menu-item-acl-resource.md create mode 100644 packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/client.ts create mode 100644 packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/errors.ts create mode 100644 packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/index.ts create mode 100644 packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/schemas.ts create mode 100644 packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts create mode 100644 packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts create mode 100644 packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts create mode 100644 packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/errors.test.ts create mode 100644 packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/schemas.test.ts create mode 100644 packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts create mode 100644 packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.test.ts diff --git a/.changeset/admin-ui-sdk-permissions.md b/.changeset/admin-ui-sdk-permissions.md new file mode 100644 index 000000000..336418c87 --- /dev/null +++ b/.changeset/admin-ui-sdk-permissions.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-api": minor +--- + +Add an Admin UI SDK permission helper for checking ACL resources with fail-closed behavior, in-flight deduplication, and TTL caching. 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-api/README.md b/packages/aio-commerce-lib-api/README.md index c9c7a35ce..12c54b7f2 100644 --- a/packages/aio-commerce-lib-api/README.md +++ b/packages/aio-commerce-lib-api/README.md @@ -14,6 +14,47 @@ pnpm add @adobe/aio-commerce-lib-api See the [Usage Guide](docs/usage.md) for more information. +## Admin UI SDK Permission Helper + +Use `getAdminUiSdkPermissionClient({ httpClient, cacheTtlMs?, denyOnError? })` to check Admin UI SDK ACL resources from a SPA or runtime action. The helper calls the Commerce Admin UI SDK permission endpoint, caches successful endpoint results for 5 minutes by default, deduplicates concurrent checks for the same resource, and fails closed by returning `false` from `check` on network or response-shape errors. A `401` response from `check` or `require` always throws because it indicates an authentication configuration issue. Set `denyOnError: false` to throw on all errors. + +```ts +import { + AdobeCommerceHttpClient, + getAdminUiSdkPermissionClient, + withAdminUiSdkPermission, +} from "@adobe/aio-commerce-lib-api"; + +const httpClient = new AdobeCommerceHttpClient({ + config: { + baseUrl, + flavor: "paas", + }, + auth: { + getHeaders: () => ({ Authorization: `Bearer ${accessToken}` }), + }, +}); +const permissions = getAdminUiSdkPermissionClient({ httpClient }); + +// SPA bootstrap +const allowed = await permissions.check("Acme_Promotions::dashboard"); +if (!allowed) { + renderAccessDenied(); +} + +// Runtime action +export const handler = withAdminUiSdkPermission( + "Acme_Promotions::edit", + permissions, + async (params) => { + // Runs only if the current user has the resource granted. + return { statusCode: 200, body: params }; + }, +); +``` + +Call `permissions.invalidate(resource)` or `permissions.invalidate()` when permission grants change and cached results should be refreshed. + ## Architecture The library is built on top of [Ky](https://github.com/sindresorhus/ky), a modern HTTP client based on Fetch API, providing: diff --git a/packages/aio-commerce-lib-api/docs/usage.md b/packages/aio-commerce-lib-api/docs/usage.md index bc41ea21b..67b556add 100644 --- a/packages/aio-commerce-lib-api/docs/usage.md +++ b/packages/aio-commerce-lib-api/docs/usage.md @@ -178,6 +178,70 @@ export const main = async function (params) { The resolver automatically detects flavor from the URL and auth type from the provided parameters. Define actual values in your `.env` file. +### Checking Admin UI SDK Permissions + +Use `getAdminUiSdkPermissionClient` when your Admin UI SDK extension needs to gate a SPA route or runtime action by an ACL resource declared in the app registration. The client calls the Commerce permission endpoint, caches successful endpoint results for 5 minutes by default, and deduplicates concurrent checks for the same resource. + +`check(resource)` returns `false` on network or response-shape errors by default so callers fail closed. A `401` response always throws from `check` and `require` because it indicates an authentication configuration issue. Set `denyOnError: false` to throw on all errors. Call `invalidate(resource)` or `invalidate()` when grants change and cached results should be refreshed. + +SPA bootstrap example: + +```typescript +import { + AdobeCommerceHttpClient, + getAdminUiSdkPermissionClient, + resolveCommerceHttpClientParams, +} from "@adobe/aio-commerce-lib-api"; + +async function bootstrapAdminRoute(params) { + const commerceClient = new AdobeCommerceHttpClient( + resolveCommerceHttpClientParams(params, { tryForwardAuthProvider: true }), + ); + const permissions = getAdminUiSdkPermissionClient({ + httpClient: commerceClient, + }); + + const allowed = await permissions.check("Acme_Promotions::dashboard"); + if (!allowed) { + renderAccessDenied(); + return; + } + + renderDashboard(); +} +``` + +Runtime action wrapper example: + +```typescript +import { + AdobeCommerceHttpClient, + getAdminUiSdkPermissionClient, + resolveCommerceHttpClientParams, + withAdminUiSdkPermission, +} from "@adobe/aio-commerce-lib-api"; + +async function updatePromotion(params) { + return { statusCode: 200, body: { updated: true } }; +} + +export const main = async (params) => { + const commerceClient = new AdobeCommerceHttpClient( + resolveCommerceHttpClientParams(params, { tryForwardAuthProvider: true }), + ); + const permissions = getAdminUiSdkPermissionClient({ + httpClient: commerceClient, + }); + const protectedHandler = withAdminUiSdkPermission( + "Acme_Promotions::edit", + permissions, + updatePromotion, + ); + + return protectedHandler(params); +}; +``` + ### Creating API Clients The `ApiClient` class allows you to bind API functions to HTTP clients, creating a clean interface for your API operations: diff --git a/packages/aio-commerce-lib-api/package.json b/packages/aio-commerce-lib-api/package.json index 56cc32e27..1d0530a90 100644 --- a/packages/aio-commerce-lib-api/package.json +++ b/packages/aio-commerce-lib-api/package.json @@ -96,7 +96,8 @@ "@adobe/aio-commerce-lib-core": "workspace:*", "camelcase": "catalog:", "ky": "catalog:", - "type-fest": "catalog:" + "type-fest": "catalog:", + "valibot": "catalog:" }, "devDependencies": { "@aio-commerce-sdk/config-tsdown": "workspace:*", diff --git a/packages/aio-commerce-lib-api/source/index.ts b/packages/aio-commerce-lib-api/source/index.ts index e24b44080..2dbcef44f 100644 --- a/packages/aio-commerce-lib-api/source/index.ts +++ b/packages/aio-commerce-lib-api/source/index.ts @@ -13,6 +13,7 @@ /** biome-ignore-all lint/performance/noBarrelFile: This is the entrypoint of the package API */ export { ApiClient } from "./lib/api-client"; +export * from "./lib/commerce/admin-ui-sdk-permissions"; export { resolveCommerceHttpClientParams } from "./lib/commerce/helpers"; export { AdobeCommerceHttpClient } from "./lib/commerce/http-client"; export { resolveIoEventsHttpClientParams } from "./lib/io-events/helpers"; diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/client.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/client.ts new file mode 100644 index 000000000..b7b90d39b --- /dev/null +++ b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/client.ts @@ -0,0 +1,159 @@ +/* + * 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 * as v from "valibot"; + +import { + AdminUiSdkPermissionDeniedError, + AdminUiSdkPermissionError, +} from "./errors"; +import { permissionCheckResponseSchema } from "./schemas"; + +import type { + AdminUiSdkPermissionClient, + AdminUiSdkPermissionClientOptions, +} from "./types"; + +const DEFAULT_CACHE_TTL_MS = 300_000; +const CHECK_ENDPOINT = "adminuisdk/permission/check"; + +type PermissionCheckResult = { + allowed: boolean; + cacheable: boolean; +}; + +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 raw = await httpClient + .post(CHECK_ENDPOINT, { + json: { resource }, + }) + .json(); + const parsed = v.safeParse(permissionCheckResponseSchema, raw); + + if (!parsed.success) { + throw new AdminUiSdkPermissionError("Unexpected response shape"); + } + + return { + allowed: parsed.output.allowed, + cacheable: true, + }; + } catch (error) { + if (isUnauthorizedError(error)) { + throw new AdminUiSdkPermissionError("Unauthorized", { cause: error }); + } + + if (denyOnError) { + return { + allowed: false, + 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 result.allowed; + }, + async require(resource: string) { + const result = await resolveCheck(resource); + + 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-api/source/lib/commerce/admin-ui-sdk-permissions/errors.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/errors.ts new file mode 100644 index 000000000..126f5e485 --- /dev/null +++ b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/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-api/source/lib/commerce/admin-ui-sdk-permissions/index.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/index.ts new file mode 100644 index 000000000..014f63e19 --- /dev/null +++ b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/index.ts @@ -0,0 +1,20 @@ +/* + * 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 entrypoint for Admin UI SDK permission helpers */ + +export * from "./client"; +export * from "./errors"; +export * from "./schemas"; +export * from "./with-permission"; + +export type * from "./types"; diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/schemas.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/schemas.ts new file mode 100644 index 000000000..d8e4bb66c --- /dev/null +++ b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/schemas.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-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts new file mode 100644 index 000000000..f9b2ae958 --- /dev/null +++ b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts @@ -0,0 +1,39 @@ +/* + * 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 type { AdobeCommerceHttpClient } from "../http-client"; + +/** 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; + httpClient: AdobeCommerceHttpClient; +} + +/** Client for checking the current user's Admin UI SDK resource permissions. */ +export interface AdminUiSdkPermissionClient { + check(resource: string): Promise; + invalidate(resource?: string): void; + require(resource: string): Promise; +} + +/** Parameters for wrapping a runtime action with an Admin UI SDK permission check. */ +export type WithAdminUiSdkPermissionParams< + TParams = Record, + TResult = unknown, +> = { + resource: string; + client: AdminUiSdkPermissionClient; + handler: (params: TParams) => Promise; +}; diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts new file mode 100644 index 000000000..ab51ef9b1 --- /dev/null +++ b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts @@ -0,0 +1,38 @@ +/* + * 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 type { AdminUiSdkPermissionClient } from "./types"; + +const DENIED_RESPONSE = { + statusCode: 403, + body: { error: "Forbidden" }, +} as const; + +/** Wraps an App Builder action handler with an Admin UI SDK permission check. */ +export function withAdminUiSdkPermission< + TParams = Record, + TResult = unknown, +>( + resource: string, + client: AdminUiSdkPermissionClient, + handler: (params: TParams) => Promise, +): (params: TParams) => Promise { + return async (params: TParams) => { + try { + await client.require(resource); + } catch { + return DENIED_RESPONSE; + } + + return handler(params); + }; +} diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts new file mode 100644 index 000000000..ffbe4a936 --- /dev/null +++ b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts @@ -0,0 +1,305 @@ +/* + * 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 { getAdminUiSdkPermissionClient } from "#lib/commerce/admin-ui-sdk-permissions/client"; +import { + AdminUiSdkPermissionDeniedError, + AdminUiSdkPermissionError, +} from "#lib/commerce/admin-ui-sdk-permissions/errors"; +import { TestAdobeCommerceHttpClient } from "#test/fixtures/http-clients"; + +const BASE_URL = "https://commerce.test"; +const CHECK_URL = `${BASE_URL}/rest/all/V1/adminuisdk/permission/check`; +type FetchInput = Parameters[0]; + +const clientParams = { + config: { + baseUrl: BASE_URL, + flavor: "paas" as const, + }, + auth: { + getHeaders: () => ({ Authorization: "Bearer test-token" }), + }, + fetchOptions: { + retry: 0, + }, +}; + +function makeHttpClient(fetchMock: typeof fetch) { + return new TestAdobeCommerceHttpClient(clientParams, fetchMock); +} + +function readRequestJson(input: FetchInput) { + if (input instanceof Request) { + return input.clone().json(); + } + + throw new Error("Expected ky to call fetch with a Request instance"); +} + +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: FetchInput) => { + capturedUrl = input instanceof Request ? input.url : String(input); + capturedBody = await readRequestJson(input); + 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 () => { + let resolveFirstResponse: (response: Response) => void = () => { + /* noop — overwritten in the Promise constructor below */ + }; + 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); + }); +}); diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/errors.test.ts b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/errors.test.ts new file mode 100644 index 000000000..aef2f2598 --- /dev/null +++ b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/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 "#lib/commerce/admin-ui-sdk-permissions/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", () => { + const error = new AdminUiSdkPermissionError("err"); + + expect(CommerceSdkErrorBase.isSdkError(error)).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-api/test/unit/lib/commerce/admin-ui-sdk-permissions/schemas.test.ts b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/schemas.test.ts new file mode 100644 index 000000000..0a2013360 --- /dev/null +++ b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/schemas.test.ts @@ -0,0 +1,46 @@ +/* + * 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"; +import { describe, expect, it } from "vitest"; + +import { permissionCheckResponseSchema } from "#lib/commerce/admin-ui-sdk-permissions/schemas"; + +describe("permissionCheckResponseSchema", () => { + it("accepts { allowed: true }", () => { + expect( + v.safeParse(permissionCheckResponseSchema, { allowed: true }).success, + ).toBe(true); + }); + + it("accepts { allowed: false }", () => { + expect( + v.safeParse(permissionCheckResponseSchema, { allowed: false }).success, + ).toBe(true); + }); + + it("rejects allowed as a string", () => { + expect( + v.safeParse(permissionCheckResponseSchema, { allowed: "yes" }).success, + ).toBe(false); + }); + + it("rejects an empty object", () => { + expect(v.safeParse(permissionCheckResponseSchema, {}).success).toBe(false); + }); + + it("rejects null", () => { + expect(v.safeParse(permissionCheckResponseSchema, null).success).toBe( + false, + ); + }); +}); diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts new file mode 100644 index 000000000..182a3315b --- /dev/null +++ b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts @@ -0,0 +1,75 @@ +/* + * 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, expectTypeOf, it } from "vitest"; + +import { + getAdminUiSdkPermissionClient, + withAdminUiSdkPermission, +} from "#index"; + +import type { + AdminUiSdkPermissionClient, + AdminUiSdkPermissionClientOptions, + WithAdminUiSdkPermissionParams, +} from "#lib/commerce/admin-ui-sdk-permissions/types"; +import type { AdobeCommerceHttpClient } from "#lib/commerce/http-client"; + +describe("AdminUiSdkPermissionClientOptions", () => { + it("requires httpClient", () => { + expectTypeOf().toMatchTypeOf<{ + httpClient: AdobeCommerceHttpClient; + }>(); + }); + + it("makes cacheTtlMs and denyOnError optional", () => { + const opts: AdminUiSdkPermissionClientOptions = { + httpClient: {} as AdobeCommerceHttpClient, + }; + + expectTypeOf(opts.cacheTtlMs).toEqualTypeOf(); + expectTypeOf(opts.denyOnError).toEqualTypeOf(); + }); +}); + +describe("AdminUiSdkPermissionClient", () => { + it("has check, require, and invalidate", () => { + expectTypeOf().toEqualTypeOf< + (resource: string) => Promise + >(); + expectTypeOf().toEqualTypeOf< + (resource: string) => Promise + >(); + expectTypeOf().toEqualTypeOf< + (resource?: string) => void + >(); + }); +}); + +describe("WithAdminUiSdkPermissionParams", () => { + it("describes a resource, client, and handler", () => { + expectTypeOf< + WithAdminUiSdkPermissionParams<{ foo: string }, { statusCode: number }> + >().toMatchTypeOf<{ + resource: string; + client: AdminUiSdkPermissionClient; + handler: (params: { foo: string }) => Promise<{ statusCode: number }>; + }>(); + }); +}); + +describe("package exports", () => { + it("exports the permission client factory and action wrapper", () => { + expectTypeOf(getAdminUiSdkPermissionClient).toBeFunction(); + expectTypeOf(withAdminUiSdkPermission).toBeFunction(); + }); +}); diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.test.ts b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.test.ts new file mode 100644 index 000000000..bf20bc510 --- /dev/null +++ b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.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 { AdminUiSdkPermissionDeniedError } from "#lib/commerce/admin-ui-sdk-permissions/errors"; +import { withAdminUiSdkPermission } from "#lib/commerce/admin-ui-sdk-permissions/with-permission"; + +import type { AdminUiSdkPermissionClient } from "#lib/commerce/admin-ui-sdk-permissions/types"; + +function makeClient(allowed: boolean): AdminUiSdkPermissionClient { + return { + check: vi.fn().mockResolvedValue(allowed), + require: vi.fn().mockImplementation((resource: string) => { + if (!allowed) { + throw new AdminUiSdkPermissionDeniedError(resource); + } + }), + invalidate: vi.fn(), + }; +} + +describe("withAdminUiSdkPermission", () => { + it("calls the handler with original params when allowed", async () => { + const handler = vi.fn().mockResolvedValue({ statusCode: 200 }); + const wrapped = withAdminUiSdkPermission( + "Acme_Promotions::dashboard", + makeClient(true), + handler, + ); + const params = { foo: "bar" }; + const result = await wrapped(params); + + expect(handler).toHaveBeenCalledWith(params); + expect(result).toEqual({ statusCode: 200 }); + }); + + it("returns 403 without calling handler when denied", async () => { + const handler = vi.fn(); + const wrapped = withAdminUiSdkPermission( + "Acme_Promotions::dashboard", + makeClient(false), + handler, + ); + const result = await wrapped({ foo: "bar" }); + + expect(handler).not.toHaveBeenCalled(); + expect(result).toMatchObject({ statusCode: 403 }); + }); + + it("returns 403 on permission check errors", async () => { + const errClient: AdminUiSdkPermissionClient = { + check: vi.fn().mockResolvedValue(false), + require: vi.fn().mockRejectedValue(new Error("network error")), + invalidate: vi.fn(), + }; + const handler = vi.fn(); + const wrapped = withAdminUiSdkPermission( + "Acme_Promotions::dashboard", + errClient, + handler, + ); + const result = await wrapped({}); + + expect(handler).not.toHaveBeenCalled(); + expect(result).toMatchObject({ statusCode: 403 }); + }); +}); diff --git a/packages/aio-commerce-lib-api/vitest.config.ts b/packages/aio-commerce-lib-api/vitest.config.ts index 1a02549bb..6edf2315f 100644 --- a/packages/aio-commerce-lib-api/vitest.config.ts +++ b/packages/aio-commerce-lib-api/vitest.config.ts @@ -15,6 +15,7 @@ import { defineConfig, mergeConfig } from "vitest/config"; const BARREL_FILES = [ "./source/index.ts", + "./source/lib/commerce/admin-ui-sdk-permissions/index.ts", "./source/utils/http/index.ts", "./source/utils/transformations/index.ts", ]; diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index 619cd7ca9..0491568e2 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -477,6 +477,10 @@ adminUiSdk: { sortOrder: 1, isSection: false, sandbox: "allow-modals", + aclResource: { + id: "Acme_Promotions::dashboard", + title: "Promotions Dashboard", + }, }, ], @@ -595,6 +599,28 @@ 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; `id` is required and `title` is optional. Commerce uses this resource to decide whether the menu item is visible to the current admin user. + +Minimal menu item ACL example: + +```javascript +adminUiSdk: { + registration: { + menuItems: [ + { + id: "promotions/dashboard", + title: "Promotions", + aclResource: { + id: "Acme_Promotions::dashboard", + title: "Promotions Dashboard", + }, + }, + ], + }, +} +``` + +Use the same `aclResource.id` with the `@adobe/aio-commerce-lib-api` Admin UI SDK permission helper when gating the SPA route or runtime actions for that menu item. ##### Order Extension Points: 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 56615932e..d76a56dfb 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 @@ -233,6 +233,11 @@ const BannerNotificationSchema = v.object({ orderViewButtons: v.optional(v.array(OrderViewButtonBannerSchema)), }); +const AclResourceSchema = v.object({ + id: nonEmptyStringValueSchema("ACL resource ID"), + title: v.optional(nonEmptyStringValueSchema("ACL resource title")), +}); + const MenuItemSchema = v.object({ id: nonEmptyStringValueSchema("menu item ID"), title: v.optional(nonEmptyStringValueSchema("menu item title")), @@ -240,6 +245,7 @@ const MenuItemSchema = v.object({ sortOrder: v.optional(v.number()), isSection: v.optional(booleanValueSchema("isSection")), sandbox: v.optional(SandboxSchema), + aclResource: v.optional(AclResourceSchema), }); /** diff --git a/packages/aio-commerce-lib-app/test/fixtures/config.ts b/packages/aio-commerce-lib-app/test/fixtures/config.ts index a399ad8ad..a71971ff6 100644 --- a/packages/aio-commerce-lib-app/test/fixtures/config.ts +++ b/packages/aio-commerce-lib-app/test/fixtures/config.ts @@ -234,6 +234,10 @@ export const configWithFullAdminUiSdk = { 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/unit/config/schema/admin-ui-sdk.test.ts b/packages/aio-commerce-lib-app/test/unit/config/schema/admin-ui-sdk.test.ts index bc028183b..7d606c7af 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,39 @@ 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 id only", () => { + const result = v.safeParse(AdminUiSdkSchema, { + registration: { + menuItems: [ + { + id: "promotions/dashboard", + aclResource: { id: "Acme_Promotions::dashboard" }, + }, + ], + }, + }); + + expect(result.success).toBe(true); + }); + test("order mass action with selectionLimit", () => { const result = v.safeParse(AdminUiSdkSchema, { registration: { @@ -310,6 +343,31 @@ describe("AdminUiSdkSchema", () => { 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("custom fee missing required value — parse fails", () => { const result = v.safeParse(AdminUiSdkSchema, { registration: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b78970234..1ddda1e2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,9 @@ importers: type-fest: specifier: 'catalog:' version: 5.6.0 + valibot: + specifier: 'catalog:' + version: 1.3.1(typescript@6.0.3) devDependencies: '@aio-commerce-sdk/config-tsdown': specifier: workspace:* From a182244e1b48a68b151ba39eb180263c50290f71 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Mon, 4 May 2026 15:20:37 -0500 Subject: [PATCH 02/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions --- .../source/config/schema/admin-ui-sdk.ts | 4 +++- .../unit/config/schema/admin-ui-sdk.test.ts | 24 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) 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 d76a56dfb..f81e045d9 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,7 +235,9 @@ const BannerNotificationSchema = v.object({ const AclResourceSchema = v.object({ id: nonEmptyStringValueSchema("ACL resource ID"), - title: v.optional(nonEmptyStringValueSchema("ACL resource title")), + title: nonEmptyStringValueSchema("ACL resource title"), + parent: v.optional(nonEmptyStringValueSchema("ACL resource parent")), + sortOrder: v.optional(positiveNumberValueSchema("ACL resource sortOrder")), }); const MenuItemSchema = v.object({ 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 7d606c7af..edd61dd02 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 @@ -83,7 +83,27 @@ describe("AdminUiSdkSchema", () => { expect(result.success).toBe(true); }); - test("menu item with aclResource id only", () => { + 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("menu item with aclResource missing title — parse fails", () => { const result = v.safeParse(AdminUiSdkSchema, { registration: { menuItems: [ @@ -95,7 +115,7 @@ describe("AdminUiSdkSchema", () => { }, }); - expect(result.success).toBe(true); + expect(result.success).toBe(false); }); test("order mass action with selectionLimit", () => { From dcc5c5722658542f7deeaf1f6e07078b8255af43 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 5 May 2026 15:18:07 -0500 Subject: [PATCH 03/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions --- .../source/config/schema/admin-ui-sdk.ts | 24 +++++- .../test/fixtures/config.ts | 4 +- .../commands/generate/actions.test.ts | 2 +- .../commands/hooks/pre-app-build.test.ts | 2 +- .../commands/generate/actions/lib.test.ts | 2 +- .../unit/config/schema/admin-ui-sdk.test.ts | 76 +++++++++++++++++++ 6 files changed, 102 insertions(+), 8 deletions(-) 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 f81e045d9..b5b7c809e 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 @@ -233,15 +233,33 @@ 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: nonEmptyStringValueSchema("ACL resource ID"), + id: aclResourceIdSchema("ACL resource ID"), title: nonEmptyStringValueSchema("ACL resource title"), - parent: v.optional(nonEmptyStringValueSchema("ACL resource parent")), + 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()), diff --git a/packages/aio-commerce-lib-app/test/fixtures/config.ts b/packages/aio-commerce-lib-app/test/fixtures/config.ts index a71971ff6..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,7 +228,7 @@ export const configWithFullAdminUiSdk = { registration: { menuItems: [ { - id: "my-app::first", + id: "my_app::first", title: "App on App Builder", parent: "my-app::apps", sortOrder: 1, 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 edd61dd02..3df85913f 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 @@ -363,6 +363,63 @@ 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: { @@ -388,6 +445,25 @@ describe("AdminUiSdkSchema", () => { 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: { From 02f79b2b7b35ab399d8b0dbb4224d2f2ef1ac7a4 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 5 May 2026 18:29:24 -0500 Subject: [PATCH 04/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions --- .../admin-ui-sdk-permissions/index.ts | 1 - .../admin-ui-sdk-permissions/types.ts | 24 +++++++++++-------- .../with-permission.ts | 9 +++++-- .../admin-ui-sdk-permissions/types.test.ts | 13 ---------- .../with-permission.test.ts | 12 ++++------ 5 files changed, 26 insertions(+), 33 deletions(-) diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/index.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/index.ts index 014f63e19..cd2767531 100644 --- a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/index.ts +++ b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/index.ts @@ -14,7 +14,6 @@ export * from "./client"; export * from "./errors"; -export * from "./schemas"; export * from "./with-permission"; export type * from "./types"; diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts index f9b2ae958..8c14ad76c 100644 --- a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts +++ b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts @@ -23,17 +23,21 @@ export interface AdminUiSdkPermissionClientOptions { /** 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 cancels deduplication of any in-flight requests. + */ invalidate(resource?: string): void; + /** + * Resolves when the current user has the given resource granted. + * Throws `AdminUiSdkPermissionDeniedError` if denied. + * Always throws `AdminUiSdkPermissionError` on 401, regardless of `denyOnError`. + */ require(resource: string): Promise; } - -/** Parameters for wrapping a runtime action with an Admin UI SDK permission check. */ -export type WithAdminUiSdkPermissionParams< - TParams = Record, - TResult = unknown, -> = { - resource: string; - client: AdminUiSdkPermissionClient; - handler: (params: TParams) => Promise; -}; diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts index ab51ef9b1..8d0c7620e 100644 --- a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts +++ b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +import { AdminUiSdkPermissionDeniedError } from "./errors"; + import type { AdminUiSdkPermissionClient } from "./types"; const DENIED_RESPONSE = { @@ -29,8 +31,11 @@ export function withAdminUiSdkPermission< return async (params: TParams) => { try { await client.require(resource); - } catch { - return DENIED_RESPONSE; + } catch (error) { + if (error instanceof AdminUiSdkPermissionDeniedError) { + return DENIED_RESPONSE; + } + throw error; } return handler(params); diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts index 182a3315b..07be1c7b5 100644 --- a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts +++ b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts @@ -20,7 +20,6 @@ import { import type { AdminUiSdkPermissionClient, AdminUiSdkPermissionClientOptions, - WithAdminUiSdkPermissionParams, } from "#lib/commerce/admin-ui-sdk-permissions/types"; import type { AdobeCommerceHttpClient } from "#lib/commerce/http-client"; @@ -55,18 +54,6 @@ describe("AdminUiSdkPermissionClient", () => { }); }); -describe("WithAdminUiSdkPermissionParams", () => { - it("describes a resource, client, and handler", () => { - expectTypeOf< - WithAdminUiSdkPermissionParams<{ foo: string }, { statusCode: number }> - >().toMatchTypeOf<{ - resource: string; - client: AdminUiSdkPermissionClient; - handler: (params: { foo: string }) => Promise<{ statusCode: number }>; - }>(); - }); -}); - describe("package exports", () => { it("exports the permission client factory and action wrapper", () => { expectTypeOf(getAdminUiSdkPermissionClient).toBeFunction(); diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.test.ts b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.test.ts index bf20bc510..a4dcab13c 100644 --- a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.test.ts +++ b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.test.ts @@ -57,21 +57,19 @@ describe("withAdminUiSdkPermission", () => { expect(result).toMatchObject({ statusCode: 403 }); }); - it("returns 403 on permission check errors", async () => { + it("re-throws errors that are not AdminUiSdkPermissionDeniedError", async () => { + const authError = new Error("network error"); const errClient: AdminUiSdkPermissionClient = { check: vi.fn().mockResolvedValue(false), - require: vi.fn().mockRejectedValue(new Error("network error")), + require: vi.fn().mockRejectedValue(authError), invalidate: vi.fn(), }; - const handler = vi.fn(); const wrapped = withAdminUiSdkPermission( "Acme_Promotions::dashboard", errClient, - handler, + vi.fn(), ); - const result = await wrapped({}); - expect(handler).not.toHaveBeenCalled(); - expect(result).toMatchObject({ statusCode: 403 }); + await expect(wrapped({})).rejects.toThrow(authError); }); }); From cef18311b5ce55837cc01d2f4fa85e46f51fe9bf Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 5 May 2026 18:59:42 -0500 Subject: [PATCH 05/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions --- packages/aio-commerce-lib-app/docs/usage.md | 4 +-- .../unit/config/schema/admin-ui-sdk.test.ts | 30 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index 0491568e2..4e9117405 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -599,7 +599,7 @@ 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; `id` is required and `title` is optional. Commerce uses this resource to decide whether the menu item is visible to the current admin user. +- **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-api` permission helper](../../aio-commerce-lib-api/docs/usage.md#checking-admin-ui-sdk-permissions). Minimal menu item ACL example: @@ -620,8 +620,6 @@ adminUiSdk: { } ``` -Use the same `aclResource.id` with the `@adobe/aio-commerce-lib-api` Admin UI SDK permission helper when gating the SPA route or runtime actions for that menu item. - ##### Order Extension Points: - **massActions** (optional array): **`actionId`**, **`label`**, **`path`** required; `title` optional; `selectionLimit` optional positive number; `confirm.title` and `confirm.message` optional; `displayIframe` optional boolean; `timeout` optional positive number; `sandbox` optional (see above) 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 342217c80..0e9c7f2d0 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 @@ -103,21 +103,6 @@ describe("AdminUiSdkSchema", () => { expect(result.success).toBe(true); }); - 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("order mass action with selectionLimit", () => { const result = v.safeParse(AdminUiSdkSchema, { registration: { @@ -290,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: { From 6cf97a8866ae1c49e4b21f3c901662364340b918 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 5 May 2026 19:01:04 -0500 Subject: [PATCH 06/14] CEXT-6151: Fix require() JSDoc and add 401 test Update require() JSDoc to document fail-closed behavior when denyOnError: true. Add test case verifying require() throws AdminUiSdkPermissionError on 401. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/commerce/admin-ui-sdk-permissions/types.ts | 3 +++ .../admin-ui-sdk-permissions/client.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts index 8c14ad76c..f35b8c2d7 100644 --- a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts +++ b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts @@ -38,6 +38,9 @@ export interface AdminUiSdkPermissionClient { * Resolves when the current user has the given resource granted. * Throws `AdminUiSdkPermissionDeniedError` if denied. * Always throws `AdminUiSdkPermissionError` on 401, regardless of `denyOnError`. + * When `denyOnError: true` (default), network and parse errors also throw + * `AdminUiSdkPermissionDeniedError` (fail-closed). Set `denyOnError: false` + * to receive `AdminUiSdkPermissionError` instead. */ require(resource: string): Promise; } diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts index ffbe4a936..2dbd5e503 100644 --- a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts +++ b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts @@ -302,4 +302,18 @@ describe("client.require", () => { client.require("Acme_Promotions::dashboard"), ).rejects.toBeInstanceOf(AdminUiSdkPermissionDeniedError); }); + + 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.require("Acme_Promotions::dashboard"), + ).rejects.toBeInstanceOf(AdminUiSdkPermissionError); + }); }); From f83ad7a0c9ea37b3d6a69905553c68d07c5c7b56 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 5 May 2026 19:09:55 -0500 Subject: [PATCH 07/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions --- packages/aio-commerce-lib-app/source/config/index.ts | 1 + .../source/config/schema/admin-ui-sdk.ts | 3 +++ .../test/unit/config/schema/admin-ui-sdk.test.ts | 9 +++++++++ 3 files changed, 13 insertions(+) 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 1dad9d169..fe0968c04 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 @@ -319,6 +319,9 @@ export type BannerNotification = v.InferInput; /** A menu item registration entry. */ export type MenuItem = v.InferInput; +/** An ACL resource registration entry for Admin UI SDK menu items. */ +export type AclResource = v.InferInput; + /** Check if config has Admin UI SDK registration configuration. */ export function hasAdminUiSdk( config: CommerceAppConfigOutputModel, 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 0e9c7f2d0..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 @@ -604,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); + }); +}); From 16efa4c3f86d93e20d0d5d2dd5515f63b55b31a1 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 5 May 2026 23:23:48 -0500 Subject: [PATCH 08/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions --- packages/aio-commerce-lib-api/docs/usage.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/aio-commerce-lib-api/docs/usage.md b/packages/aio-commerce-lib-api/docs/usage.md index 67b556add..1ec9e9a94 100644 --- a/packages/aio-commerce-lib-api/docs/usage.md +++ b/packages/aio-commerce-lib-api/docs/usage.md @@ -180,11 +180,11 @@ The resolver automatically detects flavor from the URL and auth type from the pr ### Checking Admin UI SDK Permissions -Use `getAdminUiSdkPermissionClient` when your Admin UI SDK extension needs to gate a SPA route or runtime action by an ACL resource declared in the app registration. The client calls the Commerce permission endpoint, caches successful endpoint results for 5 minutes by default, and deduplicates concurrent checks for the same resource. +Use `getAdminUiSdkPermissionClient` when your Admin UI SDK extension needs to gate a runtime action by an ACL resource declared in the app registration. The client calls the Commerce permission endpoint, caches successful endpoint results for 5 minutes by default, and deduplicates concurrent checks for the same resource. `check(resource)` returns `false` on network or response-shape errors by default so callers fail closed. A `401` response always throws from `check` and `require` because it indicates an authentication configuration issue. Set `denyOnError: false` to throw on all errors. Call `invalidate(resource)` or `invalidate()` when grants change and cached results should be refreshed. -SPA bootstrap example: +Using `permissions.check` directly: ```typescript import { @@ -193,7 +193,7 @@ import { resolveCommerceHttpClientParams, } from "@adobe/aio-commerce-lib-api"; -async function bootstrapAdminRoute(params) { +export const main = async (params) => { const commerceClient = new AdobeCommerceHttpClient( resolveCommerceHttpClientParams(params, { tryForwardAuthProvider: true }), ); @@ -203,15 +203,19 @@ async function bootstrapAdminRoute(params) { const allowed = await permissions.check("Acme_Promotions::dashboard"); if (!allowed) { - renderAccessDenied(); - return; + return { statusCode: 403, body: { error: "Access denied" } }; } - renderDashboard(); -} + return { + statusCode: 200, + body: { + /* ... */ + }, + }; +}; ``` -Runtime action wrapper example: +Using `withAdminUiSdkPermission` to wrap a handler: ```typescript import { From 407d8cf56bcc64a235815c330ca8da9d37d47d88 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Mon, 18 May 2026 11:17:46 -0500 Subject: [PATCH 09/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions - code review --- .changeset/admin-ui-sdk-permissions.md | 5 - .../aio-commerce-lib-admin-ui-sdk/biome.jsonc | 21 ++++ .../package.json | 96 +++++++++++++++++++ .../source/api/extensions/endpoints.ts | 75 +++++++++++++++ .../source/api/extensions/schema.ts | 37 +++++++ .../source/api/extensions/types.ts | 16 ++++ .../source/api/index.ts | 20 ++++ .../source/api/permissions/endpoints.ts | 43 +++++++++ .../source/api/permissions/schema.ts} | 0 .../source}/errors.ts | 0 .../source}/index.ts | 9 +- .../source/lib/api-client.ts | 57 +++++++++++ .../source/lib/permission-client.ts} | 42 ++++++-- .../test/fixtures/http-client.ts | 45 +++++++++ .../unit/api/extensions/endpoints.test.ts | 95 ++++++++++++++++++ .../unit/api/permissions/endpoints.test.ts | 67 +++++++++++++ .../test/unit}/errors.test.ts | 8 +- .../test/unit/lib/permission-client.test.ts} | 47 ++------- .../tsconfig.json | 3 + .../tsdown.config.ts | 18 ++++ .../vitest.config.ts | 29 ++++++ packages/aio-commerce-lib-api/README.md | 41 -------- packages/aio-commerce-lib-api/docs/usage.md | 68 ------------- packages/aio-commerce-lib-api/source/index.ts | 1 - .../admin-ui-sdk-permissions/types.ts | 46 --------- .../with-permission.ts | 43 --------- .../admin-ui-sdk-permissions/schemas.test.ts | 46 --------- .../admin-ui-sdk-permissions/types.test.ts | 62 ------------ .../with-permission.test.ts | 75 --------------- .../aio-commerce-lib-api/vitest.config.ts | 1 - packages/aio-commerce-lib-app/package.json | 1 + .../installation/admin-ui-sdk/helpers.ts | 38 +++----- .../installation/admin-ui-sdk/utils.ts | 44 ++++++--- .../test/fixtures/admin-ui-sdk.ts | 30 +++--- .../installation/admin-ui-sdk/branch.test.ts | 19 ++-- .../installation/admin-ui-sdk/helpers.test.ts | 10 +- packages/aio-commerce-sdk/package.json | 88 ++++++++++++++++- .../source/admin-ui-sdk/api.ts | 15 +++ pnpm-lock.yaml | 50 +++++++++- 39 files changed, 904 insertions(+), 507 deletions(-) delete mode 100644 .changeset/admin-ui-sdk-permissions.md create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/biome.jsonc create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/package.json create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/endpoints.ts create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/schema.ts create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/types.ts create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/source/api/index.ts create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/source/api/permissions/endpoints.ts rename packages/{aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/schemas.ts => aio-commerce-lib-admin-ui-sdk/source/api/permissions/schema.ts} (100%) rename packages/{aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions => aio-commerce-lib-admin-ui-sdk/source}/errors.ts (100%) rename packages/{aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions => aio-commerce-lib-admin-ui-sdk/source}/index.ts (74%) create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/source/lib/api-client.ts rename packages/{aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/client.ts => aio-commerce-lib-admin-ui-sdk/source/lib/permission-client.ts} (70%) create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/test/fixtures/http-client.ts create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/test/unit/api/extensions/endpoints.test.ts create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/test/unit/api/permissions/endpoints.test.ts rename packages/{aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions => aio-commerce-lib-admin-ui-sdk/test/unit}/errors.test.ts (92%) rename packages/{aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts => aio-commerce-lib-admin-ui-sdk/test/unit/lib/permission-client.test.ts} (89%) create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/tsconfig.json create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/tsdown.config.ts create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/vitest.config.ts delete mode 100644 packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts delete mode 100644 packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts delete mode 100644 packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/schemas.test.ts delete mode 100644 packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts delete mode 100644 packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.test.ts create mode 100644 packages/aio-commerce-sdk/source/admin-ui-sdk/api.ts diff --git a/.changeset/admin-ui-sdk-permissions.md b/.changeset/admin-ui-sdk-permissions.md deleted file mode 100644 index 336418c87..000000000 --- a/.changeset/admin-ui-sdk-permissions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@adobe/aio-commerce-lib-api": minor ---- - -Add an Admin UI SDK permission helper for checking ACL resources with fail-closed behavior, in-flight deduplication, and TTL caching. 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/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..b32d31467 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/endpoints.ts @@ -0,0 +1,75 @@ +/* + * 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, + UninstallExtensionParamsSchema, +} from "./schema"; + +import type { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; +import type { Options } from "ky"; +import type { + ExtensionRegistrationParams, + UninstallExtensionParams, +} from "./schema"; +import type { ExtensionRegistrationResult } from "./types"; + +/** + * 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); + + return httpClient + .post("adminuisdk/extension", { + ...fetchOptions, + json: { extension }, + }) + .json(); +} + +/** + * 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 uninstallExtension( + httpClient: AdobeCommerceHttpClient, + params: UninstallExtensionParams, + fetchOptions?: Options, +): Promise { + const { workspaceName, extensionName } = parseOrThrow( + UninstallExtensionParamsSchema, + 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..10425d247 --- /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 UninstallExtensionParamsSchema = 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 UninstallExtensionParams = v.InferInput< + typeof UninstallExtensionParamsSchema +>; diff --git a/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/types.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/types.ts new file mode 100644 index 000000000..64cb5bdbc --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/types.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ + +/** Response shape returned by POST /V1/adminuisdk/extension. */ +export type ExtensionRegistrationResult = { + extensionId: string; +}; 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..d6c51120e --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/api/index.ts @@ -0,0 +1,20 @@ +/* + * 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 "./extensions/types"; +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..934244c94 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/api/permissions/endpoints.ts @@ -0,0 +1,43 @@ +/* + * 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 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 { + return httpClient + .post("adminuisdk/permission/check", { + ...fetchOptions, + json: { resource: params.resource }, + }) + .json(); +} diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/schemas.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/api/permissions/schema.ts similarity index 100% rename from packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/schemas.ts rename to packages/aio-commerce-lib-admin-ui-sdk/source/api/permissions/schema.ts diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/errors.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/errors.ts similarity index 100% rename from packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/errors.ts rename to packages/aio-commerce-lib-admin-ui-sdk/source/errors.ts diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/index.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/index.ts similarity index 74% rename from packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/index.ts rename to packages/aio-commerce-lib-admin-ui-sdk/source/index.ts index cd2767531..a285035fc 100644 --- a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/index.ts +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/index.ts @@ -10,10 +10,9 @@ * governing permissions and limitations under the License. */ -/** biome-ignore-all lint/performance/noBarrelFile: Public entrypoint for Admin UI SDK permission helpers */ +/** biome-ignore-all lint/performance/noBarrelFile: This is the `@adobe/aio-commerce-lib-admin-ui-sdk/api` entrypoint. */ -export * from "./client"; +export * from "./api/index"; export * from "./errors"; -export * from "./with-permission"; - -export type * from "./types"; +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-api/source/lib/commerce/admin-ui-sdk-permissions/client.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/lib/permission-client.ts similarity index 70% rename from packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/client.ts rename to packages/aio-commerce-lib-admin-ui-sdk/source/lib/permission-client.ts index b7b90d39b..97a166064 100644 --- a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/client.ts +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/lib/permission-client.ts @@ -13,20 +13,50 @@ import { HTTPError } from "ky"; import * as v from "valibot"; +import { permissionCheckResponseSchema } from "#api/permissions/schema"; import { AdminUiSdkPermissionDeniedError, AdminUiSdkPermissionError, -} from "./errors"; -import { permissionCheckResponseSchema } from "./schemas"; +} from "#errors"; -import type { - AdminUiSdkPermissionClient, - AdminUiSdkPermissionClientOptions, -} from "./types"; +import type { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; const DEFAULT_CACHE_TTL_MS = 300_000; const CHECK_ENDPOINT = "adminuisdk/permission/check"; +/** 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; + 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 cancels deduplication of any in-flight requests. + */ + invalidate(resource?: string): void; + /** + * Resolves when the current user has the given resource granted. + * Throws `AdminUiSdkPermissionDeniedError` if denied. + * Always throws `AdminUiSdkPermissionError` on 401, regardless of `denyOnError`. + * When `denyOnError: true` (default), network and parse errors also throw + * `AdminUiSdkPermissionDeniedError` (fail-closed). Set `denyOnError: false` + * to receive `AdminUiSdkPermissionError` instead. + */ + require(resource: string): Promise; +} + type PermissionCheckResult = { allowed: boolean; cacheable: boolean; 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..466603a66 --- /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, + uninstallExtension, +} from "#api/extensions/endpoints"; +import { BASE_URL, makeHttpClient } from "#test/fixtures/http-client"; + +const REGISTER_URL = `${BASE_URL}/rest/all/V1/adminuisdk/extension`; +const UNINSTALL_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 returns extensionId", async () => { + let capturedBody: unknown; + let capturedUrl = ""; + + const fetchMock = vi.fn(async (input: Request) => { + capturedUrl = input.url; + capturedBody = await input.clone().json(); + return Response.json({ extensionId: "ext-123" }); + }); + + const result = await registerExtension( + makeHttpClient(fetchMock as typeof fetch), + PARAMS, + ); + + expect(result).toEqual({ extensionId: "ext-123" }); + 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("uninstallExtension", () => { + 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 uninstallExtension(makeHttpClient(fetchMock as typeof fetch), { + workspaceName: "prod-workspace", + extensionName: "my-namespace", + }); + + expect(capturedMethod).toBe("DELETE"); + expect(capturedUrl).toBe(UNINSTALL_URL); + }); + + it("throws on non-2xx response", async () => { + const fetchMock = vi.fn(async () => + Response.json({ message: "Not Found" }, { status: 404 }), + ); + + await expect( + uninstallExtension(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..e6c1fd5df --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/api/permissions/endpoints.test.ts @@ -0,0 +1,67 @@ +/* + * 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(); + }); +}); diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/errors.test.ts b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/errors.test.ts similarity index 92% rename from packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/errors.test.ts rename to packages/aio-commerce-lib-admin-ui-sdk/test/unit/errors.test.ts index aef2f2598..c5d39f6c3 100644 --- a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/errors.test.ts +++ b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/errors.test.ts @@ -16,7 +16,7 @@ import { describe, expect, it } from "vitest"; import { AdminUiSdkPermissionDeniedError, AdminUiSdkPermissionError, -} from "#lib/commerce/admin-ui-sdk-permissions/errors"; +} from "#errors"; describe("AdminUiSdkPermissionError", () => { it("extends CommerceSdkErrorBase", () => { @@ -36,9 +36,9 @@ describe("AdminUiSdkPermissionError", () => { }); it("is recognised by CommerceSdkErrorBase.isSdkError", () => { - const error = new AdminUiSdkPermissionError("err"); - - expect(CommerceSdkErrorBase.isSdkError(error)).toBe(true); + expect( + CommerceSdkErrorBase.isSdkError(new AdminUiSdkPermissionError("err")), + ).toBe(true); }); }); diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/lib/permission-client.test.ts similarity index 89% rename from packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts rename to packages/aio-commerce-lib-admin-ui-sdk/test/unit/lib/permission-client.test.ts index 2dbd5e503..4721430dd 100644 --- a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/client.test.ts +++ b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/lib/permission-client.test.ts @@ -12,41 +12,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getAdminUiSdkPermissionClient } from "#lib/commerce/admin-ui-sdk-permissions/client"; import { AdminUiSdkPermissionDeniedError, AdminUiSdkPermissionError, -} from "#lib/commerce/admin-ui-sdk-permissions/errors"; -import { TestAdobeCommerceHttpClient } from "#test/fixtures/http-clients"; +} from "#errors"; +import { getAdminUiSdkPermissionClient } from "#lib/permission-client"; +import { BASE_URL, makeHttpClient } from "#test/fixtures/http-client"; -const BASE_URL = "https://commerce.test"; const CHECK_URL = `${BASE_URL}/rest/all/V1/adminuisdk/permission/check`; -type FetchInput = Parameters[0]; - -const clientParams = { - config: { - baseUrl: BASE_URL, - flavor: "paas" as const, - }, - auth: { - getHeaders: () => ({ Authorization: "Bearer test-token" }), - }, - fetchOptions: { - retry: 0, - }, -}; - -function makeHttpClient(fetchMock: typeof fetch) { - return new TestAdobeCommerceHttpClient(clientParams, fetchMock); -} - -function readRequestJson(input: FetchInput) { - if (input instanceof Request) { - return input.clone().json(); - } - - throw new Error("Expected ky to call fetch with a Request instance"); -} afterEach(() => { vi.restoreAllMocks(); @@ -56,9 +29,10 @@ 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: FetchInput) => { - capturedUrl = input instanceof Request ? input.url : String(input); - capturedBody = await readRequestJson(input); + + const fetchMock = vi.fn(async (input: Request) => { + capturedUrl = input.url; + capturedBody = await input.clone().json(); return Response.json({ allowed: true }); }); @@ -174,9 +148,8 @@ describe("client.check — TTL cache", () => { }); it("does not cache an in-flight response after invalidation", async () => { - let resolveFirstResponse: (response: Response) => void = () => { - /* noop — overwritten in the Promise constructor below */ - }; + // biome-ignore lint/suspicious/noEmptyBlockStatements: placeholder reassigned inside the Promise constructor + let resolveFirstResponse: (response: Response) => void = () => {}; const firstResponse = new Promise((resolve) => { resolveFirstResponse = resolve; }); @@ -303,7 +276,7 @@ describe("client.require", () => { ).rejects.toBeInstanceOf(AdminUiSdkPermissionDeniedError); }); - it("throws AdminUiSdkPermissionError on 401 even with denyOnError: true", async () => { + it("throws AdminUiSdkPermissionError on 401", async () => { const fetchMock = vi.fn(async () => Response.json({ message: "Unauthorized" }, { status: 401 }), ); 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-api/README.md b/packages/aio-commerce-lib-api/README.md index 12c54b7f2..c9c7a35ce 100644 --- a/packages/aio-commerce-lib-api/README.md +++ b/packages/aio-commerce-lib-api/README.md @@ -14,47 +14,6 @@ pnpm add @adobe/aio-commerce-lib-api See the [Usage Guide](docs/usage.md) for more information. -## Admin UI SDK Permission Helper - -Use `getAdminUiSdkPermissionClient({ httpClient, cacheTtlMs?, denyOnError? })` to check Admin UI SDK ACL resources from a SPA or runtime action. The helper calls the Commerce Admin UI SDK permission endpoint, caches successful endpoint results for 5 minutes by default, deduplicates concurrent checks for the same resource, and fails closed by returning `false` from `check` on network or response-shape errors. A `401` response from `check` or `require` always throws because it indicates an authentication configuration issue. Set `denyOnError: false` to throw on all errors. - -```ts -import { - AdobeCommerceHttpClient, - getAdminUiSdkPermissionClient, - withAdminUiSdkPermission, -} from "@adobe/aio-commerce-lib-api"; - -const httpClient = new AdobeCommerceHttpClient({ - config: { - baseUrl, - flavor: "paas", - }, - auth: { - getHeaders: () => ({ Authorization: `Bearer ${accessToken}` }), - }, -}); -const permissions = getAdminUiSdkPermissionClient({ httpClient }); - -// SPA bootstrap -const allowed = await permissions.check("Acme_Promotions::dashboard"); -if (!allowed) { - renderAccessDenied(); -} - -// Runtime action -export const handler = withAdminUiSdkPermission( - "Acme_Promotions::edit", - permissions, - async (params) => { - // Runs only if the current user has the resource granted. - return { statusCode: 200, body: params }; - }, -); -``` - -Call `permissions.invalidate(resource)` or `permissions.invalidate()` when permission grants change and cached results should be refreshed. - ## Architecture The library is built on top of [Ky](https://github.com/sindresorhus/ky), a modern HTTP client based on Fetch API, providing: diff --git a/packages/aio-commerce-lib-api/docs/usage.md b/packages/aio-commerce-lib-api/docs/usage.md index 1ec9e9a94..bc41ea21b 100644 --- a/packages/aio-commerce-lib-api/docs/usage.md +++ b/packages/aio-commerce-lib-api/docs/usage.md @@ -178,74 +178,6 @@ export const main = async function (params) { The resolver automatically detects flavor from the URL and auth type from the provided parameters. Define actual values in your `.env` file. -### Checking Admin UI SDK Permissions - -Use `getAdminUiSdkPermissionClient` when your Admin UI SDK extension needs to gate a runtime action by an ACL resource declared in the app registration. The client calls the Commerce permission endpoint, caches successful endpoint results for 5 minutes by default, and deduplicates concurrent checks for the same resource. - -`check(resource)` returns `false` on network or response-shape errors by default so callers fail closed. A `401` response always throws from `check` and `require` because it indicates an authentication configuration issue. Set `denyOnError: false` to throw on all errors. Call `invalidate(resource)` or `invalidate()` when grants change and cached results should be refreshed. - -Using `permissions.check` directly: - -```typescript -import { - AdobeCommerceHttpClient, - getAdminUiSdkPermissionClient, - resolveCommerceHttpClientParams, -} from "@adobe/aio-commerce-lib-api"; - -export const main = async (params) => { - const commerceClient = new AdobeCommerceHttpClient( - resolveCommerceHttpClientParams(params, { tryForwardAuthProvider: true }), - ); - const permissions = getAdminUiSdkPermissionClient({ - httpClient: commerceClient, - }); - - const allowed = await permissions.check("Acme_Promotions::dashboard"); - if (!allowed) { - return { statusCode: 403, body: { error: "Access denied" } }; - } - - return { - statusCode: 200, - body: { - /* ... */ - }, - }; -}; -``` - -Using `withAdminUiSdkPermission` to wrap a handler: - -```typescript -import { - AdobeCommerceHttpClient, - getAdminUiSdkPermissionClient, - resolveCommerceHttpClientParams, - withAdminUiSdkPermission, -} from "@adobe/aio-commerce-lib-api"; - -async function updatePromotion(params) { - return { statusCode: 200, body: { updated: true } }; -} - -export const main = async (params) => { - const commerceClient = new AdobeCommerceHttpClient( - resolveCommerceHttpClientParams(params, { tryForwardAuthProvider: true }), - ); - const permissions = getAdminUiSdkPermissionClient({ - httpClient: commerceClient, - }); - const protectedHandler = withAdminUiSdkPermission( - "Acme_Promotions::edit", - permissions, - updatePromotion, - ); - - return protectedHandler(params); -}; -``` - ### Creating API Clients The `ApiClient` class allows you to bind API functions to HTTP clients, creating a clean interface for your API operations: diff --git a/packages/aio-commerce-lib-api/source/index.ts b/packages/aio-commerce-lib-api/source/index.ts index 2dbcef44f..e24b44080 100644 --- a/packages/aio-commerce-lib-api/source/index.ts +++ b/packages/aio-commerce-lib-api/source/index.ts @@ -13,7 +13,6 @@ /** biome-ignore-all lint/performance/noBarrelFile: This is the entrypoint of the package API */ export { ApiClient } from "./lib/api-client"; -export * from "./lib/commerce/admin-ui-sdk-permissions"; export { resolveCommerceHttpClientParams } from "./lib/commerce/helpers"; export { AdobeCommerceHttpClient } from "./lib/commerce/http-client"; export { resolveIoEventsHttpClientParams } from "./lib/io-events/helpers"; diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts deleted file mode 100644 index f35b8c2d7..000000000 --- a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import type { AdobeCommerceHttpClient } from "../http-client"; - -/** 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; - 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 cancels deduplication of any in-flight requests. - */ - invalidate(resource?: string): void; - /** - * Resolves when the current user has the given resource granted. - * Throws `AdminUiSdkPermissionDeniedError` if denied. - * Always throws `AdminUiSdkPermissionError` on 401, regardless of `denyOnError`. - * When `denyOnError: true` (default), network and parse errors also throw - * `AdminUiSdkPermissionDeniedError` (fail-closed). Set `denyOnError: false` - * to receive `AdminUiSdkPermissionError` instead. - */ - require(resource: string): Promise; -} diff --git a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts b/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts deleted file mode 100644 index 8d0c7620e..000000000 --- a/packages/aio-commerce-lib-api/source/lib/commerce/admin-ui-sdk-permissions/with-permission.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { AdminUiSdkPermissionDeniedError } from "./errors"; - -import type { AdminUiSdkPermissionClient } from "./types"; - -const DENIED_RESPONSE = { - statusCode: 403, - body: { error: "Forbidden" }, -} as const; - -/** Wraps an App Builder action handler with an Admin UI SDK permission check. */ -export function withAdminUiSdkPermission< - TParams = Record, - TResult = unknown, ->( - resource: string, - client: AdminUiSdkPermissionClient, - handler: (params: TParams) => Promise, -): (params: TParams) => Promise { - return async (params: TParams) => { - try { - await client.require(resource); - } catch (error) { - if (error instanceof AdminUiSdkPermissionDeniedError) { - return DENIED_RESPONSE; - } - throw error; - } - - return handler(params); - }; -} diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/schemas.test.ts b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/schemas.test.ts deleted file mode 100644 index 0a2013360..000000000 --- a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/schemas.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import * as v from "valibot"; -import { describe, expect, it } from "vitest"; - -import { permissionCheckResponseSchema } from "#lib/commerce/admin-ui-sdk-permissions/schemas"; - -describe("permissionCheckResponseSchema", () => { - it("accepts { allowed: true }", () => { - expect( - v.safeParse(permissionCheckResponseSchema, { allowed: true }).success, - ).toBe(true); - }); - - it("accepts { allowed: false }", () => { - expect( - v.safeParse(permissionCheckResponseSchema, { allowed: false }).success, - ).toBe(true); - }); - - it("rejects allowed as a string", () => { - expect( - v.safeParse(permissionCheckResponseSchema, { allowed: "yes" }).success, - ).toBe(false); - }); - - it("rejects an empty object", () => { - expect(v.safeParse(permissionCheckResponseSchema, {}).success).toBe(false); - }); - - it("rejects null", () => { - expect(v.safeParse(permissionCheckResponseSchema, null).success).toBe( - false, - ); - }); -}); diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts deleted file mode 100644 index 07be1c7b5..000000000 --- a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/types.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { describe, expectTypeOf, it } from "vitest"; - -import { - getAdminUiSdkPermissionClient, - withAdminUiSdkPermission, -} from "#index"; - -import type { - AdminUiSdkPermissionClient, - AdminUiSdkPermissionClientOptions, -} from "#lib/commerce/admin-ui-sdk-permissions/types"; -import type { AdobeCommerceHttpClient } from "#lib/commerce/http-client"; - -describe("AdminUiSdkPermissionClientOptions", () => { - it("requires httpClient", () => { - expectTypeOf().toMatchTypeOf<{ - httpClient: AdobeCommerceHttpClient; - }>(); - }); - - it("makes cacheTtlMs and denyOnError optional", () => { - const opts: AdminUiSdkPermissionClientOptions = { - httpClient: {} as AdobeCommerceHttpClient, - }; - - expectTypeOf(opts.cacheTtlMs).toEqualTypeOf(); - expectTypeOf(opts.denyOnError).toEqualTypeOf(); - }); -}); - -describe("AdminUiSdkPermissionClient", () => { - it("has check, require, and invalidate", () => { - expectTypeOf().toEqualTypeOf< - (resource: string) => Promise - >(); - expectTypeOf().toEqualTypeOf< - (resource: string) => Promise - >(); - expectTypeOf().toEqualTypeOf< - (resource?: string) => void - >(); - }); -}); - -describe("package exports", () => { - it("exports the permission client factory and action wrapper", () => { - expectTypeOf(getAdminUiSdkPermissionClient).toBeFunction(); - expectTypeOf(withAdminUiSdkPermission).toBeFunction(); - }); -}); diff --git a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.test.ts b/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.test.ts deleted file mode 100644 index a4dcab13c..000000000 --- a/packages/aio-commerce-lib-api/test/unit/lib/commerce/admin-ui-sdk-permissions/with-permission.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { describe, expect, it, vi } from "vitest"; - -import { AdminUiSdkPermissionDeniedError } from "#lib/commerce/admin-ui-sdk-permissions/errors"; -import { withAdminUiSdkPermission } from "#lib/commerce/admin-ui-sdk-permissions/with-permission"; - -import type { AdminUiSdkPermissionClient } from "#lib/commerce/admin-ui-sdk-permissions/types"; - -function makeClient(allowed: boolean): AdminUiSdkPermissionClient { - return { - check: vi.fn().mockResolvedValue(allowed), - require: vi.fn().mockImplementation((resource: string) => { - if (!allowed) { - throw new AdminUiSdkPermissionDeniedError(resource); - } - }), - invalidate: vi.fn(), - }; -} - -describe("withAdminUiSdkPermission", () => { - it("calls the handler with original params when allowed", async () => { - const handler = vi.fn().mockResolvedValue({ statusCode: 200 }); - const wrapped = withAdminUiSdkPermission( - "Acme_Promotions::dashboard", - makeClient(true), - handler, - ); - const params = { foo: "bar" }; - const result = await wrapped(params); - - expect(handler).toHaveBeenCalledWith(params); - expect(result).toEqual({ statusCode: 200 }); - }); - - it("returns 403 without calling handler when denied", async () => { - const handler = vi.fn(); - const wrapped = withAdminUiSdkPermission( - "Acme_Promotions::dashboard", - makeClient(false), - handler, - ); - const result = await wrapped({ foo: "bar" }); - - expect(handler).not.toHaveBeenCalled(); - expect(result).toMatchObject({ statusCode: 403 }); - }); - - it("re-throws errors that are not AdminUiSdkPermissionDeniedError", async () => { - const authError = new Error("network error"); - const errClient: AdminUiSdkPermissionClient = { - check: vi.fn().mockResolvedValue(false), - require: vi.fn().mockRejectedValue(authError), - invalidate: vi.fn(), - }; - const wrapped = withAdminUiSdkPermission( - "Acme_Promotions::dashboard", - errClient, - vi.fn(), - ); - - await expect(wrapped({})).rejects.toThrow(authError); - }); -}); diff --git a/packages/aio-commerce-lib-api/vitest.config.ts b/packages/aio-commerce-lib-api/vitest.config.ts index 6edf2315f..1a02549bb 100644 --- a/packages/aio-commerce-lib-api/vitest.config.ts +++ b/packages/aio-commerce-lib-api/vitest.config.ts @@ -15,7 +15,6 @@ import { defineConfig, mergeConfig } from "vitest/config"; const BARREL_FILES = [ "./source/index.ts", - "./source/lib/commerce/admin-ui-sdk-permissions/index.ts", "./source/utils/http/index.ts", "./source/utils/transformations/index.ts", ]; 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/management/installation/admin-ui-sdk/helpers.ts b/packages/aio-commerce-lib-app/source/management/installation/admin-ui-sdk/helpers.ts index b15c3d27f..7008ee219 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, - }, - }, + const response = 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, @@ -63,21 +53,23 @@ export async function registerExtension(context: AdminUiSdkExecutionContext) { * 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( 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.uninstallExtension({ + 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..483d8aed9 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, + uninstallExtension, +} 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, + uninstallExtension, + }); +} + +/** 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..ad4c957ea 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,27 @@ 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; + uninstallExtensionImpl?: () => 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({ extensionId: "ext-123" })), + ), + uninstallExtension: vi + .fn() + .mockImplementation( + overrides?.uninstallExtensionImpl ?? (() => Promise.resolve()), + ), + } as unknown as AdminUiSdkExecutionContext["adminUiSdkClient"], }; } 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..2a2333494 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,21 @@ 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 uninstallExtension 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.uninstallExtension).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")), + uninstallExtensionImpl: () => + Promise.reject(new Error("Commerce API error")), }); await expect( @@ -102,11 +104,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")), + uninstallExtensionImpl: () => + 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..f9c87bd91 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 @@ -32,14 +32,14 @@ describe("registerExtension", () => { vi.unstubAllEnvs(); }); - test("throws enriched error when POST fails", async () => { + 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 +56,7 @@ describe("registerExtension", () => { ); const context = { ...createMockAdminUiSdkContext({ - postImpl: () => Promise.reject(httpError), + registerExtensionImpl: () => Promise.reject(httpError), }), logger, }; @@ -79,7 +79,7 @@ describe("uninstallExtension", () => { vi.unstubAllEnvs(); }); - test("warns with enriched error message when DELETE fails", async () => { + test("warns with enriched error message when uninstallExtension call fails", async () => { const logger = createMockLogger(); const httpError = makeHttpError( 500, @@ -88,7 +88,7 @@ describe("uninstallExtension", () => { ); const context = { ...createMockAdminUiSdkContext({ - deleteImpl: () => Promise.reject(httpError), + uninstallExtensionImpl: () => Promise.reject(httpError), }), logger, }; 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 ebd9f95a2..e2b445209 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': @@ -238,7 +278,7 @@ importers: version: 5.6.0 valibot: specifier: 'catalog:' - version: 1.3.1(typescript@6.0.3) + version: 1.4.0(typescript@6.0.3) devDependencies: '@aio-commerce-sdk/config-tsdown': specifier: workspace:* @@ -258,6 +298,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 @@ -573,6 +616,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 @@ -673,6 +719,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==} @@ -681,6 +728,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==} From f42a6082ca5c262562758aff7eac44b2e4cf6f3b Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Mon, 18 May 2026 11:29:55 -0500 Subject: [PATCH 10/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions - code review --- packages/aio-commerce-lib-api/package.json | 3 +-- pnpm-lock.yaml | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/aio-commerce-lib-api/package.json b/packages/aio-commerce-lib-api/package.json index 762cedba3..ab377635e 100644 --- a/packages/aio-commerce-lib-api/package.json +++ b/packages/aio-commerce-lib-api/package.json @@ -96,8 +96,7 @@ "@adobe/aio-commerce-lib-core": "workspace:*", "camelcase": "catalog:", "ky": "catalog:", - "type-fest": "catalog:", - "valibot": "catalog:" + "type-fest": "catalog:" }, "devDependencies": { "@aio-commerce-sdk/config-tsdown": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2b445209..788d89c8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,9 +276,6 @@ importers: type-fest: specifier: 'catalog:' version: 5.6.0 - valibot: - specifier: 'catalog:' - version: 1.4.0(typescript@6.0.3) devDependencies: '@aio-commerce-sdk/config-tsdown': specifier: workspace:* From 92f523d179d8b95eb8a3fc006e6acf81ac4b4561 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Mon, 18 May 2026 12:17:54 -0500 Subject: [PATCH 11/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions - code review --- .changeset/add-admin-ui-sdk-library.md | 5 + .../docs/usage.md | 109 ++++++++++++++++++ .../source/config/schema/admin-ui-sdk.ts | 6 +- 3 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 .changeset/add-admin-ui-sdk-library.md create mode 100644 packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md diff --git a/.changeset/add-admin-ui-sdk-library.md b/.changeset/add-admin-ui-sdk-library.md new file mode 100644 index 000000000..da2bd1943 --- /dev/null +++ b/.changeset/add-admin-ui-sdk-library.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-admin-ui-sdk": minor +--- + +Add `@adobe/aio-commerce-lib-admin-ui-sdk` library for checking Admin UI SDK ACL resource permissions. 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..b0fba8ab4 --- /dev/null +++ b/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md @@ -0,0 +1,109 @@ +# `@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 { createAdminUiSdkApiClient } from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; +import { getAdminUiSdkPermissionClient } from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; + +const httpClient = createAdminUiSdkApiClient({ /* 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 (use in SPA contexts) +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(); +``` + +To opt out of fail-closed behavior and receive the underlying error on network failures: + +```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) { + // 401 or (when denyOnError: false) network/parse 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, + uninstallExtension, +} from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; + +const client = createCustomAdminUiSdkApiClient(params, { + registerExtension, + uninstallExtension, +}); +``` 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 1955d0591..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 @@ -352,10 +352,12 @@ export type BannerNotification = v.InferInput; */ export type MenuItem = v.InferInput; -/** An ACL resource registration entry for Admin UI SDK menu items. */ +/** + * 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. */ /** * Check if config has Admin UI SDK registration configuration. * @experimental From a17ddd4b34ecbd2a08c2f562db048226169f70ca Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Mon, 18 May 2026 13:01:46 -0500 Subject: [PATCH 12/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions - code review --- packages/aio-commerce-lib-app/docs/usage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index acb921c69..7479fcac9 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -601,7 +601,7 @@ 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-api` permission helper](../../aio-commerce-lib-api/docs/usage.md#checking-admin-ui-sdk-permissions). +- **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: From d33ae2402ee9b1f03ac265958e94c12457a7a35f Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 19 May 2026 13:48:38 -0500 Subject: [PATCH 13/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions - refactor --- .changeset/add-admin-ui-sdk-library.md | 2 ++ .../docs/usage.md | 6 ++-- .../source/api/extensions/endpoints.ts | 23 ++++++------- .../source/api/extensions/schema.ts | 6 ++-- .../source/api/extensions/types.ts | 16 ---------- .../source/api/index.ts | 1 - .../source/lib/permission-client.ts | 1 + .../unit/api/extensions/endpoints.test.ts | 18 +++++------ .../installation/admin-ui-sdk/branch.ts | 4 +-- .../installation/admin-ui-sdk/helpers.ts | 12 +++---- .../installation/admin-ui-sdk/utils.ts | 4 +-- .../test/fixtures/admin-ui-sdk.ts | 9 +++--- .../installation/admin-ui-sdk/branch.test.ts | 16 ++++++---- .../installation/admin-ui-sdk/helpers.test.ts | 32 ++++++++++++++++--- 14 files changed, 77 insertions(+), 73 deletions(-) delete mode 100644 packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/types.ts diff --git a/.changeset/add-admin-ui-sdk-library.md b/.changeset/add-admin-ui-sdk-library.md index da2bd1943..32f506bec 100644 --- a/.changeset/add-admin-ui-sdk-library.md +++ b/.changeset/add-admin-ui-sdk-library.md @@ -1,5 +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/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md b/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md index b0fba8ab4..2c9c59d02 100644 --- a/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md +++ b/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md @@ -18,10 +18,12 @@ For a complete list of all available types, functions, and classes, see the [API 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 { createAdminUiSdkApiClient } from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; +import { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; import { getAdminUiSdkPermissionClient } from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; -const httpClient = createAdminUiSdkApiClient({ /* CommerceHttpClientParams */ }); +const httpClient = new AdobeCommerceHttpClient({ + /* CommerceHttpClientParams */ +}); const permissions = getAdminUiSdkPermissionClient({ httpClient }); // Returns true if granted, false if denied or on error (fail-closed by default) 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 index b32d31467..195fdac7d 100644 --- 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 @@ -14,16 +14,15 @@ import { parseOrThrow } from "@aio-commerce-sdk/common-utils/valibot"; import { ExtensionRegistrationParamsSchema, - UninstallExtensionParamsSchema, + UnregisterExtensionParamsSchema, } from "./schema"; import type { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; import type { Options } from "ky"; import type { ExtensionRegistrationParams, - UninstallExtensionParams, + UnregisterExtensionParams, } from "./schema"; -import type { ExtensionRegistrationResult } from "./types"; /** * Registers an Admin UI SDK extension with Commerce via POST /V1/adminuisdk/extension. @@ -38,15 +37,13 @@ export async function registerExtension( httpClient: AdobeCommerceHttpClient, params: ExtensionRegistrationParams, fetchOptions?: Options, -): Promise { +): Promise { const extension = parseOrThrow(ExtensionRegistrationParamsSchema, params); - return httpClient - .post("adminuisdk/extension", { - ...fetchOptions, - json: { extension }, - }) - .json(); + await httpClient.post("adminuisdk/extension", { + ...fetchOptions, + json: { extension }, + }); } /** @@ -58,13 +55,13 @@ export async function registerExtension( * * @throws An `HTTPError` if the status code is not 2XX. */ -export async function uninstallExtension( +export async function unregisterExtension( httpClient: AdobeCommerceHttpClient, - params: UninstallExtensionParams, + params: UnregisterExtensionParams, fetchOptions?: Options, ): Promise { const { workspaceName, extensionName } = parseOrThrow( - UninstallExtensionParamsSchema, + UnregisterExtensionParamsSchema, params, ); 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 index 10425d247..ecf50fba1 100644 --- 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 @@ -21,7 +21,7 @@ export const ExtensionRegistrationParamsSchema = v.object({ }); /** Parameters for DELETE /V1/adminuisdk/extension/{workspaceName}/{extensionName}. */ -export const UninstallExtensionParamsSchema = v.object({ +export const UnregisterExtensionParamsSchema = v.object({ workspaceName: v.string(), extensionName: v.string(), }); @@ -32,6 +32,6 @@ export type ExtensionRegistrationParams = v.InferInput< >; /** The parameters accepted by DELETE /V1/adminuisdk/extension/{workspaceName}/{extensionName}. */ -export type UninstallExtensionParams = v.InferInput< - typeof UninstallExtensionParamsSchema +export type UnregisterExtensionParams = v.InferInput< + typeof UnregisterExtensionParamsSchema >; diff --git a/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/types.ts b/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/types.ts deleted file mode 100644 index 64cb5bdbc..000000000 --- a/packages/aio-commerce-lib-admin-ui-sdk/source/api/extensions/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/** Response shape returned by POST /V1/adminuisdk/extension. */ -export type ExtensionRegistrationResult = { - extensionId: string; -}; 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 index d6c51120e..c4011a7ee 100644 --- a/packages/aio-commerce-lib-admin-ui-sdk/source/api/index.ts +++ b/packages/aio-commerce-lib-admin-ui-sdk/source/api/index.ts @@ -16,5 +16,4 @@ export * from "./extensions/endpoints"; export * from "./permissions/endpoints"; export type * from "./extensions/schema"; -export type * from "./extensions/types"; export type * from "./permissions/schema"; 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 index 97a166064..094081dc3 100644 --- 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 @@ -30,6 +30,7 @@ export interface AdminUiSdkPermissionClientOptions { 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; } 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 index 466603a66..14641dab1 100644 --- 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 @@ -14,12 +14,12 @@ import { describe, expect, it, vi } from "vitest"; import { registerExtension, - uninstallExtension, + 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 UNINSTALL_URL = `${BASE_URL}/rest/all/V1/adminuisdk/extension/prod-workspace/my-namespace`; +const UNREGISTER_URL = `${BASE_URL}/rest/all/V1/adminuisdk/extension/prod-workspace/my-namespace`; const PARAMS = { extensionName: "my-namespace", @@ -29,14 +29,14 @@ const PARAMS = { }; describe("registerExtension", () => { - it("POSTs to /V1/adminuisdk/extension with extension body and returns extensionId", async () => { + 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 Response.json({ extensionId: "ext-123" }); + return new Response(null, { status: 204 }); }); const result = await registerExtension( @@ -44,7 +44,7 @@ describe("registerExtension", () => { PARAMS, ); - expect(result).toEqual({ extensionId: "ext-123" }); + expect(result).toBeUndefined(); expect(capturedUrl).toBe(REGISTER_URL); expect(capturedBody).toEqual({ extension: PARAMS }); }); @@ -60,7 +60,7 @@ describe("registerExtension", () => { }); }); -describe("uninstallExtension", () => { +describe("unregisterExtension", () => { it("sends DELETE to /V1/adminuisdk/extension/{workspaceName}/{extensionName}", async () => { let capturedUrl = ""; let capturedMethod = ""; @@ -71,13 +71,13 @@ describe("uninstallExtension", () => { return Promise.resolve(new Response(null, { status: 200 })); }); - await uninstallExtension(makeHttpClient(fetchMock as typeof fetch), { + await unregisterExtension(makeHttpClient(fetchMock as typeof fetch), { workspaceName: "prod-workspace", extensionName: "my-namespace", }); expect(capturedMethod).toBe("DELETE"); - expect(capturedUrl).toBe(UNINSTALL_URL); + expect(capturedUrl).toBe(UNREGISTER_URL); }); it("throws on non-2xx response", async () => { @@ -86,7 +86,7 @@ describe("uninstallExtension", () => { ); await expect( - uninstallExtension(makeHttpClient(fetchMock as typeof fetch), { + unregisterExtension(makeHttpClient(fetchMock as typeof fetch), { workspaceName: "prod-workspace", extensionName: "my-namespace", }), 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 7008ee219..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 @@ -27,7 +27,7 @@ export async function registerExtension(context: AdminUiSdkExecutionContext) { logger.info(`Registering Admin UI SDK extension: ${appData.projectName}`); - const response = await adminUiSdkClient + await adminUiSdkClient .registerExtension({ extensionName: process.env.__OW_NAMESPACE ?? "", extensionTitle: appData.projectTitle, @@ -42,11 +42,7 @@ 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."); } /** @@ -55,7 +51,7 @@ export async function registerExtension(context: AdminUiSdkExecutionContext) { * * @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 { adminUiSdkClient, appData, logger } = context; @@ -66,7 +62,7 @@ export async function uninstallExtension( ); try { - await adminUiSdkClient.uninstallExtension({ + await adminUiSdkClient.unregisterExtension({ workspaceName: appData.workspaceName, extensionName, }); 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 483d8aed9..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 @@ -13,7 +13,7 @@ import { createCustomAdminUiSdkApiClient, registerExtension, - uninstallExtension, + unregisterExtension, } from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; import { resolveCommerceHttpClientParams } from "@adobe/aio-commerce-lib-api"; @@ -37,7 +37,7 @@ function createAdminUiSdkClient(params: RuntimeActionParams) { return createCustomAdminUiSdkApiClient(commerceClientParams, { registerExtension, - uninstallExtension, + unregisterExtension, }); } 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 ad4c957ea..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 @@ -19,7 +19,7 @@ import type { AdminUiSdkExecutionContext } from "#management/installation/admin- /** Creates a mock AdminUiSdkExecutionContext with Admin UI SDK client methods. */ export function createMockAdminUiSdkContext(overrides?: { registerExtensionImpl?: () => Promise; - uninstallExtensionImpl?: () => Promise; + unregisterExtensionImpl?: () => Promise; }): AdminUiSdkExecutionContext { const mockInstallation = createMockInstallationContext(); @@ -29,13 +29,12 @@ export function createMockAdminUiSdkContext(overrides?: { registerExtension: vi .fn() .mockImplementation( - overrides?.registerExtensionImpl ?? - (() => Promise.resolve({ extensionId: "ext-123" })), + overrides?.registerExtensionImpl ?? (() => Promise.resolve()), ), - uninstallExtension: vi + unregisterExtension: vi .fn() .mockImplementation( - overrides?.uninstallExtensionImpl ?? (() => Promise.resolve()), + overrides?.unregisterExtensionImpl ?? (() => Promise.resolve()), ), } as unknown as AdminUiSdkExecutionContext["adminUiSdkClient"], }; 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 2a2333494..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,20 +82,22 @@ describe("admin-ui-sdk installation module", () => { expect(registerExtensionStep.meta.uninstall).toBeDefined(); }); - test("should call uninstallExtension with 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.adminUiSdkClient.uninstallExtension).toHaveBeenCalledWith({ - workspaceName: context.appData.workspaceName, - extensionName: "test-namespace", - }); + expect(context.adminUiSdkClient.unregisterExtension).toHaveBeenCalledWith( + { + workspaceName: context.appData.workspaceName, + extensionName: "test-namespace", + }, + ); }); test("should not throw when the uninstall call fails (best-effort)", async () => { const context = createMockAdminUiSdkContext({ - uninstallExtensionImpl: () => + unregisterExtensionImpl: () => Promise.reject(new Error("Commerce API error")), }); @@ -108,7 +110,7 @@ describe("admin-ui-sdk installation module", () => { const logger = createMockLogger(); const context = { ...createMockAdminUiSdkContext({ - uninstallExtensionImpl: () => + 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 f9c87bd91..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,6 +32,28 @@ describe("registerExtension", () => { vi.unstubAllEnvs(); }); + 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, @@ -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 uninstallExtension call 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({ - uninstallExtensionImpl: () => Promise.reject(httpError), + unregisterExtensionImpl: () => Promise.reject(httpError), }), logger, }; - await uninstallExtension(context); + await unregisterExtension(context); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("test-ns"), From 8142c692bd9ed9dad449943c1577f4988383c7dd Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 19 May 2026 14:47:55 -0500 Subject: [PATCH 14/14] CEXT-6151: [SDK] Add ACL admin UI schema and helper to check permissions - refactor --- .../docs/usage.md | 10 ++--- .../source/api/permissions/endpoints.ts | 10 ++++- .../source/lib/permission-client.ts | 45 +++++++++---------- .../unit/api/permissions/endpoints.test.ts | 10 +++++ .../test/unit/lib/permission-client.test.ts | 38 ++++++++++++++++ 5 files changed, 81 insertions(+), 32 deletions(-) diff --git a/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md b/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md index 2c9c59d02..42b37f5fd 100644 --- a/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md +++ b/packages/aio-commerce-lib-admin-ui-sdk/docs/usage.md @@ -29,7 +29,7 @@ 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 (use in SPA contexts) +// 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) @@ -39,7 +39,7 @@ permissions.invalidate("Vendor_Module::resource_name"); permissions.invalidate(); ``` -To opt out of fail-closed behavior and receive the underlying error on network failures: +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({ @@ -76,7 +76,7 @@ try { if (error instanceof AdminUiSdkPermissionDeniedError) { console.error(`Access denied for resource: ${error.resource}`); } else if (error instanceof AdminUiSdkPermissionError) { - // 401 or (when denyOnError: false) network/parse error + // Unauthorized, network, or response parsing error console.error("Permission check failed", error); } } @@ -101,11 +101,11 @@ In install/uninstall actions where only a subset of operations is needed, prefer import { createCustomAdminUiSdkApiClient, registerExtension, - uninstallExtension, + unregisterExtension, } from "@adobe/aio-commerce-lib-admin-ui-sdk/api"; const client = createCustomAdminUiSdkApiClient(params, { registerExtension, - uninstallExtension, + unregisterExtension, }); ``` 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 index 934244c94..9ced0ed73 100644 --- 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 @@ -10,6 +10,10 @@ * 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"; @@ -34,10 +38,12 @@ export async function checkPermission( params: PermissionCheckParams, fetchOptions?: Options, ): Promise { - return httpClient + const raw = await httpClient .post("adminuisdk/permission/check", { ...fetchOptions, json: { resource: params.resource }, }) - .json(); + .json(); + + return parseOrThrow(permissionCheckResponseSchema, raw); } 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 index 094081dc3..cba235cd8 100644 --- 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 @@ -11,9 +11,8 @@ */ import { HTTPError } from "ky"; -import * as v from "valibot"; -import { permissionCheckResponseSchema } from "#api/permissions/schema"; +import { checkPermission } from "#api/permissions/endpoints"; import { AdminUiSdkPermissionDeniedError, AdminUiSdkPermissionError, @@ -22,7 +21,6 @@ import { import type { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; const DEFAULT_CACHE_TTL_MS = 300_000; -const CHECK_ENDPOINT = "adminuisdk/permission/check"; /** Options used to create an Admin UI SDK permission client. */ export interface AdminUiSdkPermissionClientOptions { @@ -44,24 +42,26 @@ export interface AdminUiSdkPermissionClient { check(resource: string): Promise; /** * Clears the cached result for `resource`. If called without an argument, clears - * all cached entries and cancels deduplication of any in-flight requests. + * 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. - * Always throws `AdminUiSdkPermissionError` on 401, regardless of `denyOnError`. - * When `denyOnError: true` (default), network and parse errors also throw - * `AdminUiSdkPermissionDeniedError` (fail-closed). Set `denyOnError: false` - * to receive `AdminUiSdkPermissionError` instead. + * Throws `AdminUiSdkPermissionError` on 401, network, and parse errors. */ require(resource: string): Promise; } -type PermissionCheckResult = { - allowed: boolean; - cacheable: boolean; -}; +type PermissionCheckResult = + | { + allowed: boolean; + cacheable: true; + } + | { + error: AdminUiSdkPermissionError; + cacheable: false; + }; function isUnauthorizedError(error: unknown) { return error instanceof HTTPError && error.response.status === 401; @@ -89,19 +89,10 @@ export function getAdminUiSdkPermissionClient( async function fetchCheck(resource: string): Promise { try { - const raw = await httpClient - .post(CHECK_ENDPOINT, { - json: { resource }, - }) - .json(); - const parsed = v.safeParse(permissionCheckResponseSchema, raw); - - if (!parsed.success) { - throw new AdminUiSdkPermissionError("Unexpected response shape"); - } + const result = await checkPermission(httpClient, { resource }); return { - allowed: parsed.output.allowed, + allowed: result.allowed, cacheable: true, }; } catch (error) { @@ -111,7 +102,7 @@ export function getAdminUiSdkPermissionClient( if (denyOnError) { return { - allowed: false, + error: toPermissionError(error), cacheable: false, }; } @@ -167,11 +158,15 @@ export function getAdminUiSdkPermissionClient( return { async check(resource: string) { const result = await resolveCheck(resource); - return result.allowed; + 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); } 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 index e6c1fd5df..954308c00 100644 --- 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 @@ -64,4 +64,14 @@ describe("checkPermission", () => { }), ).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/lib/permission-client.test.ts b/packages/aio-commerce-lib-admin-ui-sdk/test/unit/lib/permission-client.test.ts index 4721430dd..bdde3aae9 100644 --- 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 @@ -276,6 +276,44 @@ describe("client.require", () => { ).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 }),