Self-Service Extension SDK — Architecture & Implementation Plan¶
Historical planning reference: The registry, Scalprum wiring and package proposals below preserve an earlier design; they are not native integration instructions. Use SDK architecture and the plugin author guide for the current implementation.
Status: Draft Author: AI-assisted design session Scope: Extract reusable components and create an extension SDK for the self-service plugin Jira: Portal Plugin Factory feature — Portal Plugin Factory: Plugin Development Guide and SDK
Positioning: This document proposes an initial extension layer for the broader Automation Portal Plugin Factory SDK. It delivers the self-service plugin's extension surface: the
backstage-rhaap-reactcomponent library andbackstage-rhaap-extension-apicontracts package. The full factory scope (portal-wide RBAC delegation, settings registration, capability metadata, scaffold CLI, contributor kit) is beyond this document's implementation boundary. The packages produced here establish the package naming, versioning, and contract patterns that the broader factory SDK will extend.Canonical references for the broader architecture:
- Ganesh's Content Experience Architecture — target state, workspace structure, plugin manifest model
- Brad's migration plan (PR #712) — 45 work packages under
docs/next/Alignment review: Brad Thornton reviewed this plan against the full architecture and identified six conflicts. Each is documented in §12 with an agreed resolution. The implementation plan below remains valid — §12 records which specific design decisions need updating as the design moves toward a supported implementation.
1. Problem Statement¶
The self-service plugin (@ansible/plugin-backstage-self-service) is a monolithic frontend plugin that tightly couples:
- Shared UI components (PageHeaderSection, SyncDialog, EmptyState, notification system, cache utilities) that could be reused by other plugins
- Hardcoded page compositions (RepositoryDetailsPage, EEDetailsPage, CollectionDetailsPage) with static tab lists that external plugins cannot extend
- API clients and types (AnsibleApiClient, EEBuildApiClient, ApiRefs) that are only accessible by depending on the entire self-service plugin
Community developers and Red Hat internal teams (e.g., content discovery, APME) cannot:
- Reuse shared components without depending on the full self-service plugin
- Add tabs, cards, or actions to existing detail pages
- Register new sections in the self-service navigation
- Build visually consistent plugins using our design tokens
Permissions context: The self-service plugin's pages are gated by permissions defined in backstage-rhaap-common/src/permissions.ts:
ansible.execution-environments.viewansible.git-repositories.viewansible.collections.viewansible.templates.viewansible.history.view
Extension contributors targeting pages behind these gates (e.g., TEMPLATE_DETAIL_CARDS) should be aware that the host page itself may be hidden from users lacking the corresponding permission. The SDK does not re-check permissions per contribution — that is the host page's responsibility.
2. Goals¶
- Exportable component library — shared UI components available as an independent package
- Extension points — well-defined slots where external plugins can contribute tabs, cards, and actions
- Zero-config registration for RHDH dynamic plugins — side-effect imports auto-register contributions
- Declarative registration via
dynamic-plugins.yaml— config-driven overrides for labels, priority, enable/disable - Framework-agnostic theming — CSS custom properties dynamically derived from the active Backstage theme
- MUI v4 theme support — automatic MUI v4 theme wrapping for contributed components (v5 support deferred to a future release when the upstream Backstage MUI v5 migration completes)
- Forward-compatible — registry pattern can be backed by Backstage's new frontend system later
3. New Packages¶
3.1 @ansible/backstage-rhaap-react — UI Component Library¶
Role: frontend-plugin-module (no plugin ID — pure library)
Reusable, self-contained React components, hooks, and utilities extracted from the self-service plugin. No plugin coupling, no API dependencies beyond Backstage core.
What moves here:
| Export | Source in self-service | Description |
|---|---|---|
PageHeaderSection |
components/common/PageHeaderSection.tsx |
Page header with sync button, tooltips, progress |
SyncDialog |
components/common/SyncDialog.tsx |
Sync confirmation/progress dialog |
SyncProgressPopover |
components/common/SyncProgressPopover.tsx |
Progress tooltip |
EmptyState |
components/common/EmptyState.tsx |
Empty state component |
EntityLinkButton |
components/common/EntityLinkButton.tsx |
Link to a catalog entity |
ScmIntegrationAuthError |
components/common/ScmIntegrationAuthError.tsx |
SCM auth error display |
SkeletonLoader |
components/Home/SkeletonLoader.tsx |
Card skeleton loading state |
NotificationProvider |
components/notifications/NotificationContext.tsx |
Toast notification context |
NotificationStack |
components/notifications/NotificationStack.tsx |
Notification display stack |
NotificationCard |
components/notifications/NotificationCard.tsx |
Individual notification card |
notificationStore |
components/notifications/notificationStore.ts |
Notification state store |
syncPollingService |
components/notifications/syncPollingService.ts |
Sync polling service |
PaginatedEntityCache |
components/common/cache/PaginatedEntityCache.ts |
Entity caching + pagination |
usePagination |
components/common/cache/usePagination.ts |
Pagination hook |
useCacheSubscription |
components/common/cache/useCacheSubscription.ts |
Cache subscription hook |
useIsSuperuser |
hooks/useIsSuperuser.ts |
Superuser check hook |
useSyncStatusPolling |
hooks/useSyncStatusPolling.ts |
Polling hook |
GitLabIcon, RedHatIcon |
components/common/icons.tsx |
Icon components |
usePageHeaderStyles |
components/common/styles.ts |
Page header style hook |
useSharedStyles |
components/common/styles.ts |
Shared style hook |
useShellPageStyles |
components/common/styles.ts |
Shell page style hook |
formatRelativeTime |
utils/timeUtils.ts |
Relative time formatter |
All types from common/types.ts |
components/common/types.ts |
SyncStatus, SyncStatusMap, etc. |
All constants from common/constants.ts |
components/common/constants.ts |
Sync category constants, intervals |
Note — domain utilities stay in self-service: >
parseMarkdownLinks(returns JSX — requires React + MUILink),parseEEDefinition(EE domain, depends onjs-yaml),fetchReadmeFromBackend, andfetchGitFileContentFromBackend(usefetchApiRef/DiscoveryApifrom@backstage/core-plugin-api— frontend data-fetching utilities, not backend code) remain in self-service. They are domain-specific to self-service pages and cannot move tobackstage-rhaap-common(which is a backend-oriented package with no React dependency) or to this UI library (which should not carry domain parsers or backend fetch logic). If a second consumer emerges, they can be extracted to a@ansible/backstage-rhaap-common-reactpackage following the Backstage*-common(isomorphic) /*-react(frontend) convention.
Also includes (new):
| Export | Description |
|---|---|
RhaapThemeProvider |
MUI v4 theme wrapper (reads from active Backstage theme) |
rhaapTokens |
Static design token constants (spacing, typography, border-radius) |
| CSS custom properties injection | --rhaap-* tokens dynamically derived from the Backstage theme at render time (see §5.2) |
Package structure:
plugins/backstage-rhaap-react/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts
│ ├── components/
│ │ ├── PageHeaderSection/
│ │ │ ├── PageHeaderSection.tsx
│ │ │ ├── PageHeaderSection.test.tsx
│ │ │ └── index.ts
│ │ ├── SyncDialog/
│ │ ├── SyncProgressPopover/
│ │ ├── EmptyState/
│ │ ├── EntityLinkButton/
│ │ ├── ScmIntegrationAuthError/
│ │ └── SkeletonLoader/
│ ├── notifications/
│ │ ├── NotificationContext.tsx
│ │ ├── NotificationStack.tsx
│ │ ├── NotificationCard.tsx
│ │ ├── notificationStore.ts
│ │ ├── syncPollingService.ts
│ │ └── index.ts
│ ├── cache/
│ │ ├── PaginatedEntityCache.ts
│ │ ├── usePagination.ts
│ │ ├── useCacheSubscription.ts
│ │ ├── types.ts
│ │ └── index.ts
│ ├── hooks/
│ │ ├── useIsSuperuser.ts
│ │ ├── useSyncStatusPolling.ts
│ │ └── index.ts
│ ├── theme/
│ │ ├── RhaapThemeProvider.tsx
│ │ ├── tokens.ts
│ │ └── index.ts
│ ├── icons/
│ │ └── index.tsx
│ ├── styles/
│ │ └── index.ts
│ ├── utils/
│ │ ├── timeUtils.ts
│ │ └── index.ts
│ └── types.ts
Dependencies:
{
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@backstage/core-components": "^0.18.8",
"@backstage/core-plugin-api": "^1.12.4",
"@backstage/catalog-model": "^1.7.7",
"@backstage/theme": "^0.7.2"
}
}
3.2 @ansible/backstage-rhaap-extension-api — Extension Contracts (SDK)¶
Role: frontend-plugin-module (no plugin ID — pure contracts)
Defines extension points, the contribution registry, React hooks, and convenience helpers. This is what community developers depend on to extend the self-service pages.
Core concepts:
Contribution types¶
// Types for what can be contributed
interface TabContribution {
id: string;
label: string;
icon?: React.ComponentType<{ fontSize?: 'small' | 'default' | 'large' }>;
/** Accepts lazy-loaded or synchronous components. The renderer wraps
* non-lazy components in Suspense only when needed. */
component:
| React.LazyExoticComponent<React.ComponentType<any>>
| React.ComponentType<any>;
filter?: (entity: Entity) => boolean;
/** If set, the tab is hidden from users who lack this permission.
* Evaluated by ExtensionRenderer using @backstage/plugin-permission-react.
* Only Backstage BasicPermission is supported (no ResourcePermission). */
permission?: BasicPermission;
/** Lower value = further left. Built-in tabs use 0–20.
* Community tabs should use 21+ to appear after built-ins.
* Negative priorities are allowed but will render before built-in tabs.
* Equal priorities are ordered by insertion order (stable sort). */
priority?: number;
}
interface CardContribution {
id: string;
slot: 'overview-left' | 'overview-right' | 'sidebar' | string;
component:
| React.LazyExoticComponent<React.ComponentType<any>>
| React.ComponentType<any>;
filter?: (entity: Entity) => boolean;
/** If set, the card slot is hidden from users who lack this permission. */
permission?: BasicPermission;
priority?: number;
}
interface ActionContribution {
id: string;
label: string;
icon?: React.ComponentType;
/** Action handler receives the entity and a getApi() helper for accessing any
* Backstage API ref. Contributors import the API refs they need from their own
* dependencies (e.g., catalogApiRef from @backstage/plugin-catalog-react). */
handler: (context: {
entity: Entity;
/** Resolves a Backstage API by ref. Equivalent to useApi() but callable
* outside a React component. Resolved at render time by ExtensionRenderer. */
getApi: <T>(apiRef: ApiRef<T>) => T;
}) => void | Promise<void>;
filter?: (entity: Entity) => boolean;
/** If set, the action button/menu item is hidden from users who lack this permission. */
permission?: BasicPermission;
variant?: 'button' | 'menu-item';
priority?: number;
}
Extension point IDs¶
const EXTENSION_POINTS = {
// Git Repository detail page
GIT_REPO_DETAIL_TABS: 'rhaap.git-repository.detail.tabs',
GIT_REPO_DETAIL_CARDS: 'rhaap.git-repository.detail.cards',
GIT_REPO_DETAIL_ACTIONS: 'rhaap.git-repository.detail.actions',
GIT_REPO_LIST_TABS: 'rhaap.git-repository.list.tabs',
// Execution Environments detail page
EE_DETAIL_TABS: 'rhaap.execution-environment.detail.tabs',
EE_DETAIL_CARDS: 'rhaap.execution-environment.detail.cards',
EE_DETAIL_ACTIONS: 'rhaap.execution-environment.detail.actions',
EE_LIST_TABS: 'rhaap.execution-environment.list.tabs',
// Collections detail page
COLLECTION_DETAIL_TABS: 'rhaap.collection.detail.tabs',
COLLECTION_DETAIL_CARDS: 'rhaap.collection.detail.cards',
COLLECTION_DETAIL_ACTIONS: 'rhaap.collection.detail.actions',
// Templates
TEMPLATE_DETAIL_CARDS: 'rhaap.template.detail.cards',
} as const;
Note:
SELF_SERVICE_SECTIONS(top-level navigation contributions) is deferred to navigation contributions. A navigation section has a fundamentally different shape (route, icon, label, permission check) than tabs/cards/actions and needs a dedicatedSectionContributiontype. See §8 navigation contributions for a design sketch and open questions.
Contribution registry¶
Module-level singleton that collects registrations. Supports:
registerTab(extensionPoint, contribution)→ returns unregister functionregisterCard(extensionPoint, contribution)→ returns unregister functionregisterAction(extensionPoint, contribution)→ returns unregister functiongetTabs(extensionPoint, entity?)→ sorted, filtered contributionsgetCards(extensionPoint, entity?)→ sorted, filtered contributionsgetActions(extensionPoint, entity?)→ sorted, filtered contributions
Filter safety: getTabs(), getCards(), and getActions() wrap each contribution's filter() call in a try/catch. A filter that throws is treated as "don't show this contribution" and a console.warn is emitted with the contribution ID and error. This prevents a community plugin's buggy filter from crashing the host page inside useMemo (which would bypass the per-slot ErrorBoundary).
Handler safety: ExtensionRenderer wraps every ActionContribution.handler() invocation in a try/catch (including await for async handlers that return a rejected promise). On failure, the error is surfaced via alertApi.post({ message: '...', severity: 'error' }) and logged with the contribution ID + error. The host page is unaffected. This completes the error-handling trifecta for all community-code execution paths:
| Path | Mechanism | Location |
|---|---|---|
| Render errors | ErrorBoundary per slot |
ExtensionRenderer (§5.1) |
| Filter errors | try/catch in registry getters |
ContributionRegistry (§3.2) |
| Handler errors | try/catch + alertApi notification |
ExtensionRenderer action invocation |
subscribe(listener)→ returns unsubscribe function (for React re-renders)disable(contributionId)/enable(contributionId)→ runtime enable/disablereset()→ clears all registrations (for test isolation; see §10)
Duplicate registration: If registerTab (or registerCard, registerAction) is called with an id that is already registered at the same extension point, the new registration replaces the previous one and a console warning is emitted. This matches React Fast Refresh / HMR behavior where module scope re-executes on edit.
Priority tiebreaker: Contributions at the same priority value are ordered by insertion order (stable sort). This is deterministic because Array.prototype.sort is stable in all modern JS engines.
React hooks¶
useExtensionTabs(extensionPoint, entity?)— returns sorted tabs, re-renders on registry changesuseExtensionCards(extensionPoint, entity?)— returns sorted cardsuseExtensionActions(extensionPoint, entity?)— returns sorted actions
Convenience registration helpers¶
registerGitRepoDetailTab(tab);
registerGitRepoDetailCard(card);
registerGitRepoDetailAction(action);
registerEEDetailTab(tab);
registerCollectionDetailTab(tab);
// ... one per extension point
Package structure:
plugins/backstage-rhaap-extension-api/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts
│ ├── types.ts # TabContribution, CardContribution, etc.
│ ├── extensionPoints.ts # EXTENSION_POINTS constants
│ ├── registry.ts # ContributionRegistry class
│ ├── hooks/
│ │ ├── useExtensionTabs.ts
│ │ ├── useExtensionCards.ts
│ │ ├── useExtensionActions.ts
│ │ └── index.ts
│ └── helpers/
│ ├── git-repository.ts # registerGitRepoDetailTab, etc.
│ ├── execution-environment.ts
│ ├── collection.ts
│ ├── template.ts # registerTemplateDetailCard
│ └── index.ts
Note:
ExtensionRendererandDynamicExtensionDiscoveryare not in this package. They are host-side implementation concerns that depend on@ansible/backstage-rhaap-react(forRhaapThemeProvider,SkeletonLoader),@backstage/theme, and@material-ui/core— none of which are deps of this contracts package. They live in self-service (see §6.3).
Dependencies:
{
"peerDependencies": {
"react": "^18.3.1",
"@backstage/catalog-model": "^1.7.7",
"@backstage/core-plugin-api": "^1.12.4",
"@backstage/plugin-permission-common": "^0.8.4"
}
}
Why these peers are needed:
@backstage/core-plugin-api—ApiRef<T>type for thegetApi()helper@backstage/plugin-permission-common—BasicPermissiontype for thepermissionfield on contribution typesPermission evaluation (
usePermission()) happens inExtensionRendererinside self-service, which depends on@backstage/plugin-permission-react. The contracts package only needs the type (BasicPermission), not the evaluation hook.
4. Registration Mechanisms¶
4.1 Auto-registration (primary — works for all deployments)¶
Community plugins call registration functions from their package entry point. The call runs at module load time — before React renders.
Entry point note: In this repo, only backend modules have
src/dynamic/index.tsfiles. Frontend plugins use the standardsrc/index.tsentry. RHDH loads frontend dynamic plugin bundles via module federation from the package's main entry point, not a/dynamicsub-path. Community frontend plugins should register contributions fromsrc/index.ts(or any module imported by it). The example below shows a side-effect registration that runs when the module loads.
// @acme/plugin-security-scan/src/index.ts (or any file imported at load time)
import { lazy } from 'react';
import { registerGitRepoDetailTab } from '@ansible/backstage-rhaap-extension-api';
registerGitRepoDetailTab({
id: 'acme.security-scan',
label: 'Security Scan',
component: lazy(() => import('../SecurityScanTab')),
priority: 30,
});
RHDH loads the dynamic plugin's bundle → module executes → registration completes.
No packages/ changes required. The deployer only needs to enable the plugin in dynamic-plugins.yaml:
4.2 Declarative via dynamic-plugins.yaml (secondary — config-driven control, RHDH only)¶
Note: This mechanism uses Scalprum module federation and is only available in Red Hat Developer Hub (RHDH) deployments. In standard Backstage without Scalprum,
DynamicExtensionDiscoveryis a clean no-op (see §4.4).Implementation note: There are currently zero Scalprum API imports in this codebase. The entire
DynamicExtensionDiscovery↔ Scalprum integration is net-new code with no local examples to follow. The implementation should target@scalprum/react-coreand reference RHDH's dynamic plugin documentation for the module federation scope naming convention.
For deployers who want to override labels, priority, or disable specific contributions without modifying plugin code:
plugins:
- package: '@acme/plugin-security-scan@1.2.0'
disabled: false
pluginConfig:
rhaapExtensions:
tabs:
- extensionPoint: rhaap.git-repository.detail.tabs
id: acme.security-scan
importName: SecurityScanTab
label: 'Security & Compliance' # override the default label
priority: 5 # move it before CI Activity
disabled:
- acme.some-other-tab # disable a specific contribution
Resolution mechanism: DynamicExtensionDiscovery resolves importName to a React component via Scalprum's useModule hook (or equivalent @scalprum/react-core API). Each dynamic plugin is a federated module with a known scope (the plugin package name). The resolution is:
@scalprum/react-coreretrieves the federated module for the plugin's scope- The named export matching
importNameis extracted (e.g.,SecurityScanTab) - The component is wrapped in
lazy()and registered with theContributionRegistry - If resolution fails (module not found, export missing), a warning is logged via the Backstage
LoggerService(if available in the extension host context) and viaconsole.erroras a fallback. The contribution is silently omitted — the host page continues to render without it
Observability note: In production RHDH, browser
console.erroris invisible to platform operators. A misconfiguredimportNamewill result in a missing tab with no operator-visible signal. The RHDH dynamic plugin guide (§9) should document this as a debugging step: check the browser console when a declarative contribution does not appear. A future improvement could emit a Backstage notification viaalertApifor failed resolutions (gated behind adebugconfig flag to avoid user-facing noise).
4.3 Registry singleton — shared module requirement¶
The ContributionRegistry is a module-level singleton — the shared bus between community plugins and the self-service host. This only works if all parties are talking to the same instance of backstage-rhaap-extension-api.
In RHDH, if self-service and a community plugin each bundle their own copy via --embed-package, you get two separate registries. Community plugin registrations never reach the self-service host.
The fix follows the standard Scalprum shared module pattern (same as how react is shared):
- Self-service lists
@ansible/backstage-rhaap-extension-apiin the RHDH shared scope — it provides the one shared instance - Community plugins declare it as a
peerDependencyonly — they do not embed it via--embed-package - The RHDH deployer registers it once in
dynamic-plugins.yaml:
In standard Backstage (no RHDH / no Scalprum): all plugins load in the same JS bundle so the module singleton is naturally shared — no sharedPackages configuration needed.
@ansible/backstage-rhaap-react (the component library) does not need to be shared — it has no singleton state. Each consumer can safely embed its own copy.
4.4 Async loading (handled by the registry)¶
RHDH dynamic plugins load asynchronously. A community plugin may finish loading after the self-service page has already rendered. The useExtensionTabs hook uses a subscription pattern that triggers React re-renders when late registrations arrive:
function useExtensionTabs(extensionPoint, entity?) {
const [revision, forceUpdate] = useReducer(x => x + 1, 0);
useEffect(() => contributionRegistry.subscribe(forceUpdate), []);
return useMemo(
() => contributionRegistry.getTabs(extensionPoint, entity),
[extensionPoint, entity, revision],
);
}
4.4 Non-RHDH fallback¶
DynamicExtensionDiscovery.tsx detects the runtime environment by checking for the Scalprum API (window.__scalprum__ or @scalprum/react-core availability). If Scalprum is absent (standard Backstage, no RHDH), the component is a clean no-op — it returns null and renders nothing. In this mode, only auto-registration (§4.1) is available.
5. Theme & Visual Consistency¶
5.1 MUI v4 theme wrapping with error isolation¶
Contributed components are rendered inside RhaapThemeProvider (MUI v4 only — see Goal 6 scope), ErrorBoundary, and Suspense:
<PermissionGate permission={contribution.permission}>
<RhaapThemeProvider>
<ErrorBoundary
fallback={<ContributionErrorFallback id={contribution.id} />}
>
<Suspense fallback={<SkeletonLoader />}>
<ContributedComponent entity={entity} />
</Suspense>
</ErrorBoundary>
</RhaapThemeProvider>
</PermissionGate>
PermissionGate is a thin wrapper around @backstage/plugin-permission-react's usePermission() hook. If contribution.permission is undefined, the gate is a no-op (renders children directly). If the user lacks the permission, the slot renders nothing — same behavior as the built-in sidebar items.
Each contributed component slot is individually wrapped in an ErrorBoundary. If a community plugin's component throws during render, only that slot shows a fallback (e.g., "This tab failed to load") — the rest of the host page remains intact. This is implemented in ExtensionRenderer.
MUI-based contributions inherit colors, typography, and spacing from the host theme automatically.
API resolution ownership:
ExtensionRenderer(notDynamicExtensionDiscovery) resolves Backstage APIs. It callsuseApiHolder()at render time to obtain anApiHolder, then creates agetApihelper ((apiRef) => apiHolder.get(apiRef)) that is passed intoActionContribution.handlerwhen an action is invoked.useApiHolder()(from@backstage/core-plugin-api) returns an object whose.get(apiRef)method can resolve any API ref — unlikeuseApi()which resolves a single specific ref at hook call time. This meansExtensionRenderermust be rendered inside a BackstageApiProvider— which it is when rendered within the self-service plugin tree. Community developers writing test harnesses for their contributed actions must wrap the component under test in aTestApiProviderthat supplies the API refs their handler callsgetApi()with.MUI v5 readiness: When Backstage completes its upstream MUI v5 migration,
RhaapThemeProviderwill be extended to provide a v5ThemeProvideralongside the v4 one. The@mui/materialdependency will be added at that time.
5.2 CSS custom properties (framework-agnostic, theme-derived)¶
For non-MUI contributions (PatternFly, Chakra, plain CSS, etc.), the ExtensionRenderer container element injects CSS custom properties dynamically derived from the active Backstage theme at render time via useTheme():
// Inside ExtensionRenderer
const backstageTheme = useTheme<BackstageTheme>();
const cssVars = {
'--rhaap-color-primary': backstageTheme.palette.primary.main,
'--rhaap-color-error': backstageTheme.palette.error.main,
'--rhaap-color-text': backstageTheme.palette.text.primary,
'--rhaap-color-text-secondary': backstageTheme.palette.text.secondary,
'--rhaap-color-background': backstageTheme.palette.background.default,
'--rhaap-color-border': backstageTheme.palette.divider,
'--rhaap-spacing-xs': `${backstageTheme.spacing(0.5)}px`,
'--rhaap-spacing-sm': `${backstageTheme.spacing(1)}px`,
'--rhaap-spacing-md': `${backstageTheme.spacing(2)}px`,
'--rhaap-spacing-lg': `${backstageTheme.spacing(3)}px`,
'--rhaap-spacing-xl': `${backstageTheme.spacing(4)}px`,
'--rhaap-font-family': backstageTheme.typography.fontFamily,
'--rhaap-font-size-body': backstageTheme.typography.body2.fontSize,
'--rhaap-font-size-heading': backstageTheme.typography.h6.fontSize,
'--rhaap-border-radius': `${backstageTheme.shape.borderRadius}px`,
} as React.CSSProperties;
<div className="rhaap-extension-container" style={cssVars}>
{children}
</div>;
These properties automatically adapt to dark mode, custom Backstage themes, and any theme overrides the deployer has configured.
Community developers can use these from any framework:
5.3 Static design token constants¶
For JS-based styling where dynamic theme access is not needed (e.g., fixed spacing values, icon sizes), the rhaapTokens object provides static fallback values:
import { rhaapTokens } from '@ansible/backstage-rhaap-react';
// rhaapTokens.spacing.md === 16
// rhaapTokens.borderRadius === 8
Note: These are static defaults and will not adapt to the active theme. Prefer CSS custom properties (§5.2) for theme-aware styling.
6. Self-Service Plugin Changes¶
6.1 Import migration¶
All self-service pages update their imports from local ../common/ paths to @ansible/backstage-rhaap-react. The old barrel (components/common/index.ts) becomes a re-export shim during migration.
6.2 Detail page refactoring¶
RepositoryDetailsPage, CollectionDetailsPage, and EEDetailsPage are refactored to:
- Define built-in tabs as a local array
- Merge with
useExtensionTabs()results - Sort by priority
- Render contributed components inside
ExtensionRenderer(which providesRhaapThemeProvider+ErrorBoundary+Suspense+ CSS custom properties)
6.3 Extension host initialization¶
ExtensionRenderer and DynamicExtensionDiscovery live in self-service under src/extensions/:
plugins/self-service/src/extensions/
├── ExtensionRenderer.tsx # Renders contributed tabs/cards with theme + error isolation
└── DynamicExtensionDiscovery.tsx # RHDH Scalprum discovery (no-op in standard Backstage)
ExtensionRenderer wraps each contributed component in RhaapThemeProvider → ErrorBoundary → Suspense → CSS custom properties (§5.1). It uses useApiHolder() from @backstage/core-plugin-api to create the getApi helper passed into ActionContribution.handler context. useApiHolder() returns an ApiHolder whose .get(apiRef) method resolves any API ref at call time — unlike useApi() which resolves a single specific ref.
DynamicExtensionDiscovery (rendered near the top of the self-service plugin tree) handles:
- Processing declarative configs from
dynamic-plugins.yaml - Resolving contributed components from Scalprum-loaded plugins (RHDH only; no-op in standard Backstage)
- Registering them with the
ContributionRegistry
Why these live in self-service, not extension-api:
ExtensionRendererdepends on@ansible/backstage-rhaap-react(RhaapThemeProvider,SkeletonLoader),@backstage/theme(BackstageTheme), and@material-ui/core(useTheme). Adding these to extension-api's deps would defeat its purpose as a minimal contracts package. Community plugins never importExtensionRenderer— they register contributions and the host renders them. If a second host page needs to render extensions in the future,ExtensionRenderercan be extracted to a shared package at that point.
7. Dependency Graph¶
@ansible/backstage-rhaap-react
├── peerDeps: react, react-dom, @material-ui/core, @material-ui/icons,
│ @backstage/core-components, @backstage/core-plugin-api,
│ @backstage/catalog-model, @backstage/theme
└── deps: (none — all framework deps are peers)
@ansible/backstage-rhaap-extension-api
├── peerDeps: react, @backstage/catalog-model, @backstage/core-plugin-api,
│ @backstage/plugin-permission-common
└── deps: (none — all deps are peers for type-only usage)
@ansible/backstage-rhaap-common
└── (unchanged — backend-oriented: AAPClient, service refs, permissions, types)
@ansible/plugin-backstage-self-service
├── @ansible/backstage-rhaap-react
├── @ansible/backstage-rhaap-extension-api
├── @ansible/backstage-rhaap-common
└── (retains: parseMarkdownLinks, parseEEDefinition, fetchReadmeFromBackend,
fetchGitFileContentFromBackend — domain-specific, frontend-only utilities)
Community plugin (e.g. @acme/plugin-security-scan)
├── @ansible/backstage-rhaap-extension-api (required — to register)
└── @ansible/backstage-rhaap-react (optional — to use shared UI)
8. Implementation Workstreams¶
Create backstage-rhaap-react (component library)¶
Pre-requisite: Map internal dependency chains of extracted components. Verified import chains (from codebase):
PageHeaderSection→useIsSuperuser,usePageHeaderStyles/useSharedStyles/useProgressTooltipStyles,SyncProgressPopover,formatRelativeTime, typesSyncProgressPopover→formatRelativeTime,useSharedStyles, typesSyncDialog→useSharedStyles,useNotifications(from NotificationContext), icons, constants, Backstage API typesNotificationContext→notificationStoreNotificationStack→NotificationCardNotificationCard→ MUI + local types only (no notificationStore dependency)useSyncStatusPolling→syncPollingService
⚠ Extraction blocker for
syncPollingService: It importsinvalidateCollections(fromCollectionsCatalog/collectionsInvalidation) andgitReposCache(fromGitRepositories/gitReposCache) — these are page-specific cache/state modules.syncPollingServicecannot be extracted as-is. It must be refactored to accept cache-invalidation callbacks via dependency injection (e.g., aregisterInvalidator(category, fn)pattern) before extraction. This refactor is a prerequisite for extracting the notification system.
Extraction order:
types.ts,constants.ts(no internal deps)formatRelativeTime(no internal deps)- Icons, styles (
usePageHeaderStyles,useSharedStyles,useShellPageStyles,useProgressTooltipStyles) - Hooks (
useIsSuperuser— standalone;useSyncStatusPolling— blocked onsyncPollingServicerefactor) - Simple UI components (
EmptyState,SkeletonLoader,EntityLinkButton,ScmIntegrationAuthError) SyncProgressPopover(depends onformatRelativeTime, styles, types)notificationStore(depends on local types only)NotificationCard→NotificationStack→NotificationContext(depends onnotificationStore)syncPollingService(after refactoring out page-specific imports)SyncDialog(depends on styles, icons,useNotifications, constants)PageHeaderSection(depends onuseIsSuperuser, styles,SyncProgressPopover,formatRelativeTime)- Cache utilities (
PaginatedEntityCache,usePagination,useCacheSubscription) - Theme (
RhaapThemeProvider,tokens.ts)
Tasks:
- Create package scaffolding (package.json with peerDependencies, tsconfig.json, .eslintrc.js)
- Move components from
self-service/src/components/common/with tests (in extraction order above) - Move hooks from
self-service/src/hooks/ - Move notifications from
self-service/src/components/notifications/ - Move cache utilities from
self-service/src/components/common/cache/ - Move
formatRelativeTimefromself-service/src/utils/timeUtils.ts - Refactor
syncPollingServiceto accept cache-invalidation callbacks (removeinvalidateCollectionsandgitReposCachehard imports) - Create
RhaapThemeProvider(v4 only) and static design tokens - Add re-export shims in self-service for backward compatibility
- Verify all self-service tests still pass
Create backstage-rhaap-extension-api (extension contracts)¶
- Create package scaffolding
- Implement
ContributionRegistrywith full test coverage, including: reset()method for test isolation- Duplicate-ID replacement with console warning
- Stable sort at equal priority (insertion-order tiebreaker)
- Define all
TabContribution,CardContribution,ActionContributiontypes - Define all
EXTENSION_POINTSconstants - Implement
useExtensionTabs,useExtensionCards,useExtensionActionshooks - Implement convenience registration helpers
Wire self-service detail pages¶
- Refactor
RepositoryDetailsPageto useuseExtensionTabs - Refactor
EEDetailsPageto useuseExtensionTabs - Refactor
CollectionDetailsPageto useuseExtensionTabs - Refactor
GitRepositoriesPagelist tabs to useuseExtensionTabs - Implement
ExtensionRenderercomponent (inself-service/src/extensions/): ErrorBoundaryper contribution slot- Always wrap in
Suspense(aSuspenseboundary around a non-lazy component is a no-op — it renders immediately and the fallback never activates, so there is no need to detect lazy vs synchronous components) RhaapThemeProvider(MUI v4)- CSS custom properties injection via
useTheme() getApihelper creation viauseApiHolder()— passed into action handler context at call time- Implement
DynamicExtensionDiscoveryfor RHDH Scalprum discovery (with no-op fallback) - Add CSS custom properties container wrapping
- Update all affected tests
Content discovery extraction (future — separate plan)¶
First named external consumer: APME (Automation Portal Management Experience) is the first external product team targeting the SDK for the Portal Plugin Factory. The content discovery extraction below serves as the first-party proof-of-SDK, validating the extension points before APME begins integration.
- Extract Git Repositories, Collections, EE catalog into
@ansible/plugin-backstage-content-discovery - Extract backend into
@ansible/backstage-plugin-catalog-backend-module-content-discovery - Register as first-party extension consumer (proof of SDK)
- Remove extracted components from self-service
Navigation section contributions¶
Status: Deferred. This is the hardest extension point because Backstage's routing system is designed for static, compile-time route registration. The design below is a sketch — not committed to implementation until a concrete consumer (e.g., content discovery extraction in content discovery extraction) validates the need.
Problem: A community plugin wants to add a top-level page (e.g., /self-service/compliance)
with its own sidebar link, permission gate, and routed component. The current SDK only supports
contributions within existing pages (tabs, cards, actions).
Why this is hard: Backstage's createRoutableExtension requires a RouteRef at plugin
creation time. Community plugins can't inject RouteRefs into the self-service plugin
after it's been created. This means contributed routes cannot participate in Backstage's
cross-plugin route resolution (useRouteRef()).
Contribution type (draft):
interface SectionContribution {
id: string;
label: string;
icon: React.ComponentType;
/** Relative path under /self-service/. E.g., 'compliance' → /self-service/compliance */
path: string;
component:
| React.LazyExoticComponent<React.ComponentType<any>>
| React.ComponentType<any>;
/** If set, the sidebar item and route are hidden from users lacking this permission. */
permission?: BasicPermission;
priority?: number;
}
Route injection: RouteView.tsx would read contributed sections from the registry
and render additional <Route> elements dynamically, each wrapped in <RequirePermission>:
const contributedSections = useExtensionSections();
// Inside <Routes>:
{
contributedSections.map(section => (
<Route
key={section.id}
path={section.path}
element={
section.permission ? (
<RequirePermission permission={section.permission}>
<ExtensionRenderer contribution={section} />
</RequirePermission>
) : (
<ExtensionRenderer contribution={section} />
)
}
/>
));
}
Sidebar injection: A ContributedSidebarItems component renders sidebar links
for each registered section, with permission gating:
const ContributedSidebarItems = () => {
const sections = useExtensionSections();
return sections.map(section => (
<ConditionallyRenderedSidebarItem
key={section.id}
to={`/self-service/${section.path}`}
text={section.label}
icon={section.icon}
permission={section.permission}
/>
));
};
Limitations:
- Contributed routes use raw
react-router-dom<Route>elements, not BackstageRouteRefs. This means other plugins cannotuseRouteRef()to link to contributed pages. Contributors must use hardcoded paths (/self-service/<path>) for cross-links. - Route paths must be unique across all contributions. Duplicate paths are rejected at registration time with a console error.
- Contributed pages are full-page components — they manage their own layout, data fetching, and sub-routes. The host provides only the sidebar frame, permission gate, and theme.
Open questions:
- Should contributed sections be able to define sub-routes (nested
<Route>elements)? - Should the sidebar support grouping/nesting contributed items under a parent?
- Will Backstage's new frontend system (
@backstage/frontend-plugin-api) provide a better mechanism for dynamic route registration, making this pattern obsolete?
9. Documentation Deliverables¶
| Document | Location | Content |
|---|---|---|
| Architecture doc (this file) | docs/architecture/self-service-extension-sdk.md |
Overall design, rationale, package layout |
| Component library API reference | plugins/backstage-rhaap-react/README.md |
All exported components, props, examples |
| Extension SDK guide | plugins/backstage-rhaap-extension-api/README.md |
How to register tabs/cards/actions, all extension points |
| Community developer quickstart | docs/sdk/quickstart.md |
End-to-end example: build a plugin that adds a tab |
| Design tokens reference | docs/sdk/design-tokens.md |
All CSS custom properties (dynamic) + JS tokens (static), theme guidance |
| RHDH dynamic plugin guide | docs/sdk/rhdh-dynamic-plugins.md |
Registration via dynamic-plugins.yaml, Scalprum resolution, declarative config |
| Migration guide (internal) | docs/sdk/migration-from-self-service.md |
How to update self-service imports to use new packages |
10. Testing Strategy¶
- Unit tests for
ContributionRegistry(register, unregister, filter, sort, subscribe, disable, reset, duplicate-ID replacement, priority tiebreaker, filter try/catch — verify a throwing filter omits the contribution and warns) - Hook tests for
useExtensionTabs,useExtensionCards,useExtensionActions(initial render, late registration, filtering) - Integration tests for detail pages (verify contributed tabs render alongside built-in tabs)
- Component tests for
RhaapThemeProvider(MUI v4 theme inheritance) - Component tests for
ExtensionRenderer(CSS custom properties injection, Suspense fallback, ErrorBoundary isolation — verify a throwing contributed component does not crash the host page, handler error safety — verify a throwing/rejecting action handler surfaces an alert and does not crash the host page) - All existing self-service tests must continue to pass throughout migration
- Test isolation: All test suites that interact with the
ContributionRegistrymust callregistry.reset()inafterEachto prevent state leakage between tests
11. Risks & Mitigations¶
| Risk | Mitigation |
|---|---|
| Circular dependency between new packages and self-service | Strict dependency direction enforcement; re-export shims only in self-service |
| Breaking existing dynamic plugin artifacts | shared UI extraction uses re-exports; no public API changes |
| Late-loading dynamic plugins cause UI flicker | useExtensionTabs subscription + graceful rendering (no layout shift) |
| Community plugins use incompatible React version | Peer dependency on react ^18.3.1; documented requirement |
| CSS custom property names collide with other plugins | --rhaap- prefix is unique; documented naming convention |
| Community plugin throws during render | Each contribution slot wrapped in its own ErrorBoundary; fallback UI instead of page crash |
| Registry state leaks between tests | reset() method on registry; required in afterEach per testing strategy |
| Duplicate registration in HMR / dev mode | Second registration replaces first with console warning; deterministic behavior |
| Non-deterministic ordering at equal priority | Stable sort guarantees insertion-order tiebreaker; documented |
| DynamicExtensionDiscovery fails in non-RHDH Backstage | Scalprum detection; clean no-op when absent |
| Internal dependency chains break during shared UI extraction | Dependency graph verified from actual imports; extraction order enforced; syncPollingService refactor noted as prerequisite |
| Community plugin's filter callback throws inside useMemo | Registry getters wrap filter() in try/catch; throwing filter omits contribution + logs warning |
| Community plugin's action handler throws or returns rejected promise | ExtensionRenderer wraps handler invocation in try/catch + await; surfaces error via alertApi; host page unaffected |
| Scalprum integration is net-new with no local examples | Target @scalprum/react-core API documented; RHDH docs linked; higher implementation risk flagged for detail-page integration |
| Registry singleton duplicated across dynamic plugins | backstage-rhaap-extension-api must be in RHDH sharedPackages scope; community plugins use peerDep only — no --embed-package |
syncPollingService has page-specific hard imports |
Must refactor to callback-based invalidation before extraction (documented in shared UI extraction pre-requisite) |
12. Alignment with Full Architecture¶
Brad Thornton reviewed this plan against the full architecture (Ganesh's doc + PR #712) and identified six conflicts. Each has an agreed resolution. The implementation workstreams above remain the right sequence — the items below are corrections to specific design decisions within those workstreams.
12.1 Who owns the extension host?¶
This plan: ExtensionRenderer and ContributionRegistry live in self-service. Detail
pages act as hosts.
Full architecture: portal-core owns portal-extension-host. Self-service is a
contributor to experiences, not a host. (Ganesh's doc §6.2)
Resolution: The earlier implementation's ContributionRegistry + ExtensionRenderer are the first implementation of portal-extension-host. Before production use, they move to a portal-core workspace and are published as @ansible/portal-extension-host. Self-service becomes a contributor, not a host. The detail-page integration work in §8 is still the right implementation step — it just lands in the right package at the end.
12.2 handler() vs CapabilityLaunch¶
This plan: ActionContribution.handler({ entity, getApi }) can call any Backstage API.
Full architecture: Server-side effects go through CapabilityLaunch with constrained types only: { type: 'slot' | 'workflow' | 'operation'; operationId?; workflowId? }. (Ganesh's doc §6.3, §9.2)
Resolution:
- Keep
handler()for pure UI/navigation only (open a dialog, navigate, copy to clipboard). Document clearly: no network requests. - Server-side effects (start a scan, trigger a build) must use
operationIdorworkflowId— registered operations with schemas, permissions, and audit records. - Update
ActionContributionto addlaunches?: CapabilityLaunchalongsidehandler. The extension-contract implementation should ship both.
12.3 Frozen IDs vs experiences¶
This plan: EXTENSION_POINTS constants like rhaap.collection.detail.tabs are the primary public contract.
Full architecture: Host-owned experiences (Quality, Authoring, Migration) that plugins contribute capabilities into. Per-page IDs are implementation slots inside experiences, not the public API. (Ganesh's doc §6.2)
Resolution:
- The
rhaap.*.detail.tabsIDs become compatibility aliases for current pages during the transition, not the primary contract. - No new extension point IDs should follow the
rhaap.*frozen-ID pattern. - New capability registrations should target
experienceIdonceportal-coredefines experiences. - The
EXTENSION_POINTSconstants in extension contracts are still the right step for the earlier implementation — just documented as aliases.
12.4 filter? vs appliesToContentTypes¶
This plan: filter?: (entity: Entity) => boolean is the content-type gate.
Full architecture: appliesToContentTypes: string[] is the primary, declarative gate. The host needs this statically — before React renders — to hide MCP tools and filter REST responses. (Ganesh's doc §6.3)
Resolution:
- Add
appliesToContentTypes?: string[]to all contribution types in extension contracts. filterstays as an additional UI-side predicate for narrow annotation-level cases.appliesToContentTypesis evaluated by the host before the component loads;filterruns at render time.
12.5 Single EE surface vs definition vs built image¶
This plan: EE_DETAIL_TABS / EE_LIST_TABS treat EE as one undifferentiated kind.
Full architecture: EE definition (the execution-environment.yml) and EE built image OCI artifact with digest, trust signals) are two different content types with different entry points. (Ganesh's doc §3.1)
Resolution:
- Two content type IDs from day one:
execution-environment-definitionandexecution-environment-image. - Shared UI extraction wires only the definition pages described in this plan.
- Update extension contracts to split
EE_DETAIL_TABSinto two constants with the correct type IDs.
12.6 Package naming¶
This plan: @ansible/backstage-rhaap-react, @ansible/backstage-rhaap-extension-api
Full architecture: portal-extension-common, portal-extension-api, portal-extension-host, portal-plugin-sdk — all in workspaces/portal-core/. Repository proposes renaming to automation-portal-plugins. (Ganesh's doc §2.2, §2.6)
Resolution: Rename before any external publishing. Since the earlier implementation is unpublished this costs nothing now.
| Original package name | Target name |
|---|---|
backstage-rhaap-react |
portal-plugin-sdk |
backstage-rhaap-extension-api |
portal-extension-api |
| (host, currently in self-service) | portal-extension-host |
| (contracts, currently inline) | portal-extension-common |
12.7 What the current self-service plugin actually is¶
Per Brad's analysis — the plugin today bundles four distinct concerns:
| Cluster | What it does | Target home |
|---|---|---|
| Portal chrome | SignInPage, AAPLogoutButton, AppThemeFixer, LandingPage |
portal-core / aap |
| Content catalogs | Collections, Git repos, EE catalog, sync | workspaces/content/automation-content |
| Create / run templates | Templates catalog, CreateTask, RunTask, task history |
workspaces/portal-scaffolder/ (done) |
| Scaffolder fields | 11 field extensions | Stay in self-service or scaffolder-backend-module-self-service |
After content discovery extraction, the remaining plugin (templates + tasks + fields) should
be named portal-scaffolder (plugin ID portal-scaffolder, package
@ansible/backstage-plugin-portal-scaffolder). Keep /self-service/* as redirects.
13. Compatibility Policy¶
@ansible/backstage-rhaap-extension-api is a public SDK that external consumers depend on. The following semver guarantees apply once the package reaches 1.0.0:
- Extension point IDs (
EXTENSION_POINTSvalues) are stable across minor versions. An ID is never renamed or removed in a minor release. - Contribution types (
TabContribution,CardContribution,ActionContribution) are additive-only across minor versions. New optional fields may be added; existing fields are never removed or made required. - Registry API (
registerTab,getTabs,subscribe,reset, etc.) is backward-compatible across minor versions. New methods may be added; existing method signatures are never changed. - Breaking changes (removing an extension point, changing a required field type, changing
getApisignature) require a major version bump with a migration guide.
Pre-1.0.0 releases follow 0.x.y semver: minor bumps may include breaking changes, documented in the changelog.
Note: Once packages are renamed to
portal-extension-api/portal-plugin-sdk(§12.6), the semver guarantees above transfer to the new package names. Thebackstage-rhaap-*names become re-export shims and are considered deprecated from that point.