From 8966cddbe6399103705bd421e18e7ddfe772b9fe Mon Sep 17 00:00:00 2001 From: Viljami + Claude Date: Fri, 25 Sep 2026 07:55:39 +0000 Subject: [PATCH] feat(api-reference): show required permissions from x-epilot-permissions Renders the x-epilot-permissions OpenAPI extension as a "Required permissions" note at the top of each operation in the API reference. Specs are still loaded at runtime from docs.api.epilot.io, then enriched before rendering; falls back to plain Redoc if loading fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C2A97XBNr735zXYDgBMs45 --- docs/auth/grant-actions.md | 8 +++ src/components/RedocPage.tsx | 45 +++++++++++- src/utils/openapi-permissions.ts | 113 +++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 src/utils/openapi-permissions.ts diff --git a/docs/auth/grant-actions.md b/docs/auth/grant-actions.md index 682f5e45..d53eda95 100644 --- a/docs/auth/grant-actions.md +++ b/docs/auth/grant-actions.md @@ -8,6 +8,14 @@ Complete reference of all permission grant actions supported by the epilot permi Actions follow a `{domain}:{operation}` pattern. Use `{domain}:*` to grant all operations in a domain. +Operations in the [API reference](/api/entity) list the grants they require under **Required permissions**, declared in the OpenAPI spec via the `x-epilot-permissions` extension: + +```yaml +x-epilot-permissions: + - action: entity:create + resource: '{slug}' +``` + ## Entity Entity permissions are scoped per schema using the `resource` field (e.g. `contact:*`, `opportunity:*`). diff --git a/src/components/RedocPage.tsx b/src/components/RedocPage.tsx index 708ede59..90863ceb 100644 --- a/src/components/RedocPage.tsx +++ b/src/components/RedocPage.tsx @@ -1,22 +1,61 @@ import DocPageStyles from '@docusaurus/theme-classic/lib-next/theme/DocPage/styles.module.css'; import ApiSidebar from '@site/src/components/ApiSidebar'; +import { enrichSpecWithPermissions } from '@site/src/utils/openapi-permissions'; import Layout from '@theme/Layout'; import Redoc from '@theme/Redoc'; import { ApiDocProps as Props } from 'docusaurus-theme-redoc/src/types/common'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { Loading, loadAndBundleSpec } from 'redoc'; import styles from './RedocPage.module.css'; +type SpecState = { status: 'loading' } | { status: 'loaded'; spec: Record } | { status: 'failed' }; + +/** + * Loads the spec in the browser (so the reference always shows the latest published spec) + * and renders `x-epilot-permissions` into operation descriptions. + */ +function useEnrichedSpec(specUrl: string): SpecState { + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + + setState({ status: 'loading' }); + loadAndBundleSpec(specUrl) + .then((spec) => { + const enriched = enrichSpecWithPermissions(spec) as unknown as Record; + + if (!cancelled) setState({ status: 'loaded', spec: enriched }); + }) + .catch((error) => { + console.warn(`Could not enrich ${specUrl} with permissions`, error); + + if (!cancelled) setState({ status: 'failed' }); + }); + + return () => { + cancelled = true; + }; + }, [specUrl]); + + return state; +} + function RedocPage({ layoutProps, spec: propSpec }: Props): JSX.Element { const { title = 'API Docs', description = 'Open API Reference Docs for the API' } = layoutProps || {}; - const specUrl: string | undefined = propSpec.type === 'url' ? propSpec.content : undefined; + const specUrl: string = (propSpec.type === 'url' ? propSpec.content : undefined) || propSpec.specUrl; + const specState = useEnrichedSpec(specUrl); return (
- + {specState.status === 'loading' && } + {specState.status === 'loaded' && } + {/* Fall back to plain Redoc, which shows its own error when the spec cannot be loaded */} + {specState.status === 'failed' && }
); diff --git a/src/utils/openapi-permissions.ts b/src/utils/openapi-permissions.ts new file mode 100644 index 00000000..24d0a8a5 --- /dev/null +++ b/src/utils/openapi-permissions.ts @@ -0,0 +1,113 @@ +/** + * Renders the `x-epilot-permissions` OpenAPI extension into operation descriptions, + * so the API reference shows which permissions (role grants) each operation requires. + * + * Redoc ignores unknown `x-` extensions, so we prepend a short markdown block to the + * operation description instead. The extension is a list of grants that are all required: + * + * x-epilot-permissions: + * - action: entity:create + * resource: '{slug}' + * - anyOf: + * - action: workflow:execution:task:update + * - action: workflow:execution:task:update_assigned + * + * An empty list means no specific grant is needed (any authenticated caller). + */ + +export const PERMISSIONS_EXTENSION = 'x-epilot-permissions'; + +export const PERMISSIONS_REFERENCE_URL = '/docs/auth/grant-actions'; + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +export interface Grant { + action: string; + resource?: string; +} + +export type PermissionRequirement = Grant | { anyOf: Grant[] }; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type OpenAPIDocument = { paths?: Record }; + +const isGrant = (value: unknown): value is Grant => + typeof value === 'object' && value !== null && typeof (value as Grant).action === 'string'; + +const formatGrant = (grant: Grant) => + grant.resource ? `\`${grant.action}\` on \`${grant.resource}\`` : `\`${grant.action}\``; + +const formatRequirement = (requirement: unknown): string | null => { + if (isGrant(requirement)) { + return formatGrant(requirement); + } + + const anyOf = (requirement as { anyOf?: unknown })?.anyOf; + + if (Array.isArray(anyOf)) { + const grants = anyOf.filter(isGrant).map(formatGrant); + + return grants.length ? `one of ${grants.join(' or ')}` : null; + } + + return null; +}; + +/** + * Returns the markdown shown at the top of an operation description, + * or null when the operation does not declare its permissions. + */ +export const formatPermissions = (permissions: unknown): string | null => { + if (!Array.isArray(permissions)) { + return null; + } + + const label = `**[Required permissions](${PERMISSIONS_REFERENCE_URL}):**`; + + if (permissions.length === 0) { + return `> ${label} none – any authenticated caller`; + } + + const requirements = permissions.map(formatRequirement).filter(Boolean); + + if (!requirements.length) { + return null; + } + + return `> ${label} ${requirements.join(' and ')}`; +}; + +/** + * Returns a copy of the spec with required permissions rendered into each operation description. + */ +export const enrichSpecWithPermissions = (spec: T): T => { + if (!spec?.paths) { + return spec; + } + + const paths = Object.fromEntries( + Object.entries(spec.paths).map(([path, pathItem]) => { + if (!pathItem || typeof pathItem !== 'object') { + return [path, pathItem]; + } + + const enrichedPathItem = { ...pathItem }; + + for (const method of HTTP_METHODS) { + const operation = pathItem[method]; + const permissions = formatPermissions(operation?.[PERMISSIONS_EXTENSION]); + + if (permissions) { + enrichedPathItem[method] = { + ...operation, + description: [permissions, operation.description].filter(Boolean).join('\n\n'), + }; + } + } + + return [path, enrichedPathItem]; + }), + ); + + return { ...spec, paths }; +};