diff --git a/.eslintrc.js b/.eslintrc.js
index bc5d820..4a7acee 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -10,6 +10,7 @@ config.parserOptions = {
config.rules = {
...config.rules,
+ 'react/jsx-filename-extension': ['error', { extensions: ['.jsx', '.tsx'] }],
'import/extensions': ['error', 'ignorePackages', {
js: 'never',
jsx: 'never',
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bf8dbf1..872dce6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,8 +27,6 @@ jobs:
run: npm run test
- name: Build
run: npm run build
- - name: i18n_extract
- run: npm run i18n_extract
- name: Coverage
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
diff --git a/docs/how_tos/permissions.md b/docs/how_tos/permissions.md
new file mode 100644
index 0000000..041acba
--- /dev/null
+++ b/docs/how_tos/permissions.md
@@ -0,0 +1,148 @@
+# How to: Query Permissions from openedx-authz
+
+## Overview
+
+`@openedx/frontend-authz` provides hooks and utilities to validate user permissions against the
+`openedx-authz` service. Results are cached automatically via TanStack Query to minimize calls
+to the backend.
+
+## Available permissions
+
+The full list of actions and scopes supported by the `openedx-authz` service is documented here:
+https://docs.openedx.org/projects/openedx-authz/en/latest/concepts/core_roles_and_permissions/index.html
+
+Use those action strings in your `PermissionValidationQuery` values.
+
+## Prerequisites
+
+Ensure your app root is wrapped with a `QueryClientProvider` from `@tanstack/react-query`.
+
+---
+
+## Core Concepts
+
+### Permission query shape
+
+Permissions are expressed as a key/value map where:
+- **keys** are arbitrary semantic names you choose (e.g. `canEditGrading`)
+- **values** describe the `action` string and optional `scope` (resource identifier)
+
+```typescript
+import type { PermissionValidationQuery } from '@openedx/frontend-authz';
+
+const query: PermissionValidationQuery = {
+ canViewGrading: {
+ action: 'courses.view_grading_settings',
+ scope: 'course-v1:org+course+run',
+ },
+ canEditGrading: {
+ action: 'courses.edit_grading_settings',
+ scope: 'course-v1:org+course+run',
+ },
+};
+```
+
+### Caching
+
+Results are cached using TanStack Query. The cache key includes the query object and the
+resolved `apiBaseUrl`, so different backends and different permission sets are cached
+independently. Results are reused across components that request the same permissions within
+one session.
+
+---
+
+## `usePermissions`
+
+The single hook for querying permissions. Requires a `featureEnabled` boolean — always
+pass the resolved waffle flag value so the caller explicitly opts in or out of authz.
+Permission keys are spread at the top level — no nested `.permissions` object.
+
+```typescript
+import { usePermissions } from '@openedx/frontend-authz';
+import { getConfig } from '@edx/frontend-platform';
+
+// featureEnabled is required — always pass the resolved waffle flag boolean:
+const { enableAuthz } = useWaffleFlags(resourceId);
+const { isLoading, isError, isAuthzEnabled, canViewGrading, canEditGrading } = usePermissions(
+ {
+ canViewGrading: { action: 'courses.view_grading_settings', scope: resourceId },
+ canEditGrading: { action: 'courses.edit_grading_settings', scope: resourceId },
+ },
+ enableAuthz ?? false,
+ { apiBaseUrl: getConfig().LMS_BASE_URL },
+);
+
+if (isLoading) { return ; }
+if (isError) { return ; }
+if (!canViewGrading) { return ; }
+```
+
+When `featureEnabled` is `false`: no API call is made and all keys return `true`,
+preserving the pre-authz behavior during rollout.
+
+> **Service unavailability:** if the authz API call fails, `isError` is `true` and all
+> permission keys resolve to `false`. Always check `isLoading` and `isError` before
+> rendering gated UI to avoid incorrectly denying access during transient failures.
+
+---
+
+## Recommended: create an MFE-specific wrapper
+
+Avoid calling `usePermissions` directly in every component. Create a single MFE-level
+wrapper that encapsulates the waffle flag check and base URL:
+
+```typescript
+import { usePermissions } from '@openedx/frontend-authz';
+import { getConfig } from '@edx/frontend-platform';
+import { useWaffleFlags } from './waffleHooks'; // your MFE's waffle flag hook
+import type { PermissionValidationQuery } from '@openedx/frontend-authz';
+
+export const useResourcePermissions = (
+ resourceId: string,
+ permissions: Query,
+) => {
+ const { enableAuthz } = useWaffleFlags(resourceId);
+ return usePermissions(
+ permissions,
+ enableAuthz ?? false,
+ { apiBaseUrl: getConfig().LMS_BASE_URL },
+ );
+};
+
+export const getResourcePermissions = (resourceId: string): PermissionValidationQuery => ({
+ canView: { action: 'resources.view', scope: resourceId },
+ canEdit: { action: 'resources.edit', scope: resourceId },
+});
+
+// Usage in any component:
+const { isLoading, canView, canEdit } =
+ useResourcePermissions(resourceId, getResourcePermissions(resourceId));
+```
+
+---
+
+## Best Practices
+
+- **Define permission constants** in your MFE (`COURSE_PERMISSIONS`, etc.) rather than
+ inline strings — prevents typos and makes global renames easy.
+- **Use query builder helpers** (`getGradingPermissions(courseId)`) to build the query
+ object — keeps permission definitions co-located with the feature they belong to.
+- **Do not duplicate `{ action, scope }` pairs** within a single query — only the first
+ matching key is mapped in the response.
+- **Keep `featureEnabled` close to the flag source** — the boolean should come directly
+ from your waffle flag check, not be stored in state or passed through many layers.
+
+---
+
+## Manual Cache Invalidation
+
+If user roles change mid-session and you need to force a refetch:
+
+```typescript
+import { permissionsQueryKeys } from '@openedx/frontend-authz';
+import { getConfig } from '@edx/frontend-platform';
+
+queryClient.invalidateQueries({
+ queryKey: permissionsQueryKeys.validate(myQuery, getConfig().LMS_BASE_URL),
+});
+```
diff --git a/package-lock.json b/package-lock.json
index 5347c86..d375fa3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@tanstack/react-query": "^5.0.0",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^16.2.0",
+ "@types/jest": "^29.0.0",
"husky": "7.0.4",
"jest": "29.7.0",
"react": "^18.3.1",
diff --git a/src/api.test.ts b/src/api.test.ts
new file mode 100644
index 0000000..f1dcaa8
--- /dev/null
+++ b/src/api.test.ts
@@ -0,0 +1,80 @@
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { validatePermissions, PERMISSIONS_VALIDATE_PATH } from './api';
+
+jest.mock('@edx/frontend-platform/auth', () => ({
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+
+const BASE_URL = 'http://lms.example.com';
+const QUERY = {
+ canRead: { action: 'example.read', scope: 'lib:org:test' },
+ canWrite: { action: 'example.write', scope: 'lib:org:test' },
+};
+
+describe('validatePermissions', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('posts to the correct URL', async () => {
+ const postMock = jest.fn().mockResolvedValue({ data: [] });
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ post: postMock });
+
+ await validatePermissions(BASE_URL, QUERY);
+
+ expect(postMock).toHaveBeenCalledWith(
+ `${BASE_URL}${PERMISSIONS_VALIDATE_PATH}`,
+ expect.any(Array),
+ );
+ });
+
+ it('sends all query items as an array in the request body', async () => {
+ const postMock = jest.fn().mockResolvedValue({ data: [] });
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ post: postMock });
+
+ await validatePermissions(BASE_URL, QUERY);
+
+ const body = postMock.mock.calls[0][1];
+ expect(body).toHaveLength(2);
+ expect(body).toEqual(expect.arrayContaining([
+ { action: 'example.read', scope: 'lib:org:test' },
+ { action: 'example.write', scope: 'lib:org:test' },
+ ]));
+ });
+
+ it('maps response array back to caller keys', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({
+ data: [
+ { action: 'example.read', scope: 'lib:org:test', allowed: true },
+ { action: 'example.write', scope: 'lib:org:test', allowed: false },
+ ],
+ }),
+ });
+
+ const result = await validatePermissions(BASE_URL, QUERY);
+
+ expect(result).toEqual({ canRead: true, canWrite: false });
+ });
+
+ it('defaults missing keys to false', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({ data: [] }),
+ });
+
+ const result = await validatePermissions(BASE_URL, QUERY);
+
+ expect(result).toEqual({ canRead: false, canWrite: false });
+ });
+
+ it('defaults a partially missing key to false', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({
+ data: [{ action: 'example.read', scope: 'lib:org:test', allowed: true }],
+ }),
+ });
+
+ const result = await validatePermissions(BASE_URL, QUERY);
+
+ expect(result.canRead).toBe(true);
+ expect(result.canWrite).toBe(false);
+ });
+});
diff --git a/src/api.ts b/src/api.ts
new file mode 100644
index 0000000..b37e31a
--- /dev/null
+++ b/src/api.ts
@@ -0,0 +1,32 @@
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import type {
+ PermissionValidationQuery,
+ PermissionValidationAnswer,
+ PermissionValidationRequestItem,
+ PermissionValidationResponseItem,
+} from './types';
+
+export const PERMISSIONS_VALIDATE_PATH = '/api/authz/v1/permissions/validate/me';
+
+export const validatePermissions = async (
+ apiBaseUrl: string,
+ query: Query,
+): Promise> => {
+ const request: PermissionValidationRequestItem[] = Object.values(query);
+
+ const response = await getAuthenticatedHttpClient().post(
+ `${apiBaseUrl}${PERMISSIONS_VALIDATE_PATH}`,
+ request,
+ );
+ const data = response.data as PermissionValidationResponseItem[];
+
+ const result = {} as PermissionValidationAnswer;
+
+ for (const [key, reqItem] of Object.entries(query) as [keyof Query, PermissionValidationRequestItem][]) {
+ const match = data.find(
+ (item) => item.action === reqItem.action && item.scope === reqItem.scope,
+ );
+ result[key] = match ? match.allowed : false;
+ }
+ return result;
+};
diff --git a/src/hooks.test.tsx b/src/hooks.test.tsx
new file mode 100644
index 0000000..b53fa14
--- /dev/null
+++ b/src/hooks.test.tsx
@@ -0,0 +1,145 @@
+import React from 'react';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { usePermissions, permissionsQueryKeys } from './hooks';
+
+jest.mock('@edx/frontend-platform/auth', () => ({
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(() => ({ LMS_BASE_URL: 'http://lms.example.com' })),
+}));
+
+const BASE_URL = 'http://lms.example.com';
+const QUERY = {
+ canView: { action: 'courses.view_grading_settings', scope: 'course-v1:org+course+run' },
+ canEdit: { action: 'courses.edit_grading_settings', scope: 'course-v1:org+course+run' },
+};
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ return Wrapper;
+};
+
+describe('usePermissions', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('returns actual server values when featureEnabled is true', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({
+ data: [
+ { action: 'courses.view_grading_settings', scope: 'course-v1:org+course+run', allowed: true },
+ { action: 'courses.edit_grading_settings', scope: 'course-v1:org+course+run', allowed: false },
+ ],
+ }),
+ });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.canView).toBe(true);
+ expect(result.current.canEdit).toBe(false);
+ expect(result.current.isAuthzEnabled).toBe(true);
+ expect(result.current.isError).toBe(false);
+ });
+
+ it('returns all keys as true and makes no API call when featureEnabled is false', () => {
+ const postMock = jest.fn();
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ post: postMock });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, false, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+
+ expect(postMock).not.toHaveBeenCalled();
+ expect(result.current.canView).toBe(true);
+ expect(result.current.canEdit).toBe(true);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isError).toBe(false);
+ expect(result.current.isAuthzEnabled).toBe(false);
+ });
+
+ it('defaults absent server keys to false when featureEnabled is true', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({ data: [] }),
+ });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.canView).toBe(false);
+ expect(result.current.canEdit).toBe(false);
+ });
+
+ it('spreads permission keys at the top level — no nested .permissions object', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({
+ data: [
+ { action: 'courses.view_grading_settings', scope: 'course-v1:org+course+run', allowed: true },
+ ],
+ }),
+ });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect('canView' in result.current).toBe(true);
+ expect('permissions' in result.current).toBe(false);
+ });
+
+ it('returns undefined permission keys and isLoading=true while the API call is in flight', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn(() => new Promise(() => {})),
+ });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.canView).toBeUndefined();
+ expect(result.current.canEdit).toBeUndefined();
+ });
+
+ it('sets isError=true and defaults all keys to false when the API call fails', async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockRejectedValue(new Error('network error')),
+ });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.isError).toBe(true);
+ expect(result.current.canView).toBe(false);
+ expect(result.current.canEdit).toBe(false);
+ jest.restoreAllMocks();
+ });
+
+ it('scopes cache by apiBaseUrl — different base URLs produce distinct query keys', () => {
+ const keyA = permissionsQueryKeys.validate(QUERY, 'http://lms-a.example.com');
+ const keyB = permissionsQueryKeys.validate(QUERY, 'http://lms-b.example.com');
+ expect(keyA).not.toEqual(keyB);
+ });
+});
diff --git a/src/hooks.ts b/src/hooks.ts
new file mode 100644
index 0000000..fd39ec9
--- /dev/null
+++ b/src/hooks.ts
@@ -0,0 +1,82 @@
+import { skipToken, useQuery } from '@tanstack/react-query';
+import { getConfig } from '@edx/frontend-platform';
+import type { PermissionValidationQuery, PermissionValidationAnswer } from './types';
+import { validatePermissions } from './api';
+
+export const permissionsQueryKeys = {
+ all: ['authz'] as const,
+ validate: (query: PermissionValidationQuery, apiBaseUrl: string = getConfig().LMS_BASE_URL) => [...permissionsQueryKeys.all, 'validatePermissions', apiBaseUrl, query] as const,
+};
+
+export interface UsePermissionsOptions {
+ /** Default false — authz returns definitive answers; retrying 403s wastes requests. */
+ retry?: boolean | number,
+ /**
+ * Base URL of the backend running openedx-authz.
+ * Defaults to getConfig().LMS_BASE_URL when omitted.
+ */
+ apiBaseUrl?: string,
+ /**
+ * How long (in ms) the cached result is considered fresh before TanStack Query refetches.
+ * Defaults to 5 minutes.
+ */
+ staleTime?: number,
+}
+
+export type UsePermissionsResult = {
+ isLoading: boolean,
+ isError: boolean,
+ isAuthzEnabled: boolean,
+} & Record;
+
+/**
+ * Queries the openedx-authz service for the given permissions.
+ *
+ * When `featureEnabled` is false: no API call is made; all permission keys return true,
+ * preserving pre-authz behavior during gradual rollout.
+ * When `featureEnabled` is true: posts to the authz API and maps each key in the query
+ * to the allowed boolean from the server response. Keys absent from the response default
+ * to false. Keys are undefined while the request is in flight (check isLoading first).
+ *
+ * For the full list of available actions and scopes supported by openedx-authz, see:
+ * https://docs.openedx.org/projects/openedx-authz/en/latest/concepts/core_roles_and_permissions/index.html
+ *
+ * @param query - Key/value map of permission check descriptors.
+ * @param featureEnabled - Pass the result of your waffle flag check here.
+ * @param options - Optional retry, apiBaseUrl, and staleTime settings.
+ */
+export const usePermissions = (
+ query: Query,
+ featureEnabled: boolean,
+ options: UsePermissionsOptions = {},
+): UsePermissionsResult => {
+ const {
+ retry = false,
+ apiBaseUrl = getConfig().LMS_BASE_URL,
+ staleTime = 5 * 60 * 1000,
+ } = options;
+
+ const { isLoading, isError, data } = useQuery, Error>({
+ queryKey: permissionsQueryKeys.validate(query, apiBaseUrl),
+ queryFn: featureEnabled ? () => validatePermissions(apiBaseUrl, query) : skipToken,
+ retry,
+ staleTime,
+ });
+
+ const permissionResults = isLoading
+ ? ({} as PermissionValidationAnswer)
+ : (Object.keys(query) as (keyof Query)[]).reduce(
+ (acc, key) => {
+ acc[key] = featureEnabled ? (data?.[key] ?? false) : true;
+ return acc;
+ },
+ {} as PermissionValidationAnswer,
+ );
+
+ return {
+ isLoading: featureEnabled ? isLoading : false,
+ isError: featureEnabled ? isError : false,
+ isAuthzEnabled: featureEnabled,
+ ...permissionResults,
+ } as UsePermissionsResult;
+};
diff --git a/src/index.test.ts b/src/index.test.ts
deleted file mode 100644
index 4d9c2d2..0000000
--- a/src/index.test.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { AUTHZ_VERSION } from '.';
-
-describe('frontend-authz', () => {
- it('exports AUTHZ_VERSION', () => {
- expect(AUTHZ_VERSION).toBeDefined();
- expect(typeof AUTHZ_VERSION).toBe('string');
- });
-});
diff --git a/src/index.ts b/src/index.ts
index 3a293ed..f31dfd2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1 +1,3 @@
-export const AUTHZ_VERSION = '0.1.0';
+export { usePermissions, permissionsQueryKeys } from './hooks';
+export type { UsePermissionsOptions, UsePermissionsResult } from './hooks';
+export type { PermissionValidationQuery, PermissionValidationAnswer } from './types';
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..6e72a8a
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,22 @@
+export interface PermissionValidationRequestItem {
+ action: string,
+ scope?: string,
+}
+
+export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
+ allowed: boolean,
+}
+
+export type PermissionValidationQuery = Record;
+
+/**
+ * Maps each key from the caller's query to a boolean allowed value.
+ * The generic form preserves exact key names for autocomplete and typo detection.
+ *
+ * @example
+ * const query = { canEdit: { action: 'courses.edit' } } satisfies PermissionValidationQuery;
+ * const answer: PermissionValidationAnswer = { canEdit: true };
+ */
+export type PermissionValidationAnswer<
+ Query extends PermissionValidationQuery = PermissionValidationQuery,
+> = Record;