Third-party Portal plugin author guide¶
Use the Portal contracts to contribute functionality without modifying self-service or importing another optional plugin's implementation. Declare frontend extensions statically through NFS and register backend operations through injected host services.
This guide describes the current experimental SDK for teams developing plugins
inside this repository's workspaces. The preview uses workspace:^ SDK
dependencies and the shared root lockfile; npm publication is not required.
Supported-release compatibility guarantees and backend authorization closure
are not yet complete. Use this guide for coordinated internal development,
not as a production-readiness certification.
Internal developer-preview contract¶
This section defines the proposed contract for coordinated internal plugin development. The implemented behavior below is available on the SDK branch; the compatibility and migration policy still requires agreement between SDK maintainers and consuming teams. This document does not announce a published preview release or certify every existing backend path.
Preview API boundary¶
The preview surface consists of the documented shared contracts in
@ansible/portal-extension-common, native Blueprints and descriptors in
@ansible/portal-extension-api/alpha, operation and entitlement clients,
documented frontend SDK utilities, and injected backend services from
@ansible/portal-plugin-node. Use the API entry points and native interaction
contracts listed below; an exported type alone does not make every field or
launch combination supported.
The host owns contribution validation, attachment regions, rendering, and
navigation. Contributor packages must not import another optional plugin's
implementation or take over host composition. Legacy registry helpers and
module-load registration are compatibility-only, not the native authoring API.
Use host-owned attachments rather than imperative moduleName resolution.
Manifests must use the actual Backstage plugin ID, owner-prefixed capability
and entry-point IDs, a known experienceId, and canonical content types.
Entry-point scope must not broaden capability scope. Although the shared type
still contains appliesTo, native validators reject that manifest predicate;
put executable predicates on frontend descriptors as isApplicable instead.
The supported launch subset is:
- Slot contributions render through the matching native Blueprint and its allowed host attachment; a slot is not an arbitrary module or route resolver.
- Entity actions and catalog item actions invoke registered operations using the current subject.
- An entity operation action may follow on to an eligible entity detail tab.
Declare a slot follow-on targeting
entity.detail.tabsand the matching descriptorfollowOnEntryPointId. Navigation follows a successful operation response; it is not proof that a background job has completed. - Catalog-header actions start workflows mapped by
ScaffolderWorkflowBlueprintto registered scaffolder template references. They do not accept arbitrary workflow URLs.
Catalog item action follow-ons, operation-to-workflow follow-ons, arbitrary
multi-step launch chains, and workflow launches on other action surfaces are
outside this preview subset. A CapabilityLaunch union member is not a promise
that a host renderer supports it on every surface.
Backend guarantees and author responsibilities¶
The operation dispatcher requires an authenticated user, trusted organization
context, a registered owner-prefixed operation ID, a permission, and REST
exposure. It checks RBAC, validates supplied input schemas, and enforces every
declared requiredEntitlements requirement before calling the handler. It
validates supplied output schemas after the handler returns. Schemas remain
optional in the current TypeScript descriptor; preview authors should define
input/output schemas for their public operation contracts.
The handler must independently authorize the subject resource, verify its supported content type, and scope domain data appropriately. Frontend filtering does not provide backend resource authorization. A deployment administrator configures entitlement grants; plugin authors declare requirements and register their definitions.
For shared resources, any trusted configured membership with the required grant can enable the capability, subject to operation and resource RBAC. Git repositories do not acquire AAP organization ownership. Organization-specific operations require one validated organization; multiple memberships do not implicitly select the first organization. There is no SDK organization selector.
The following are not dispatcher guarantees:
idempotentmetadata does not deduplicate requests, provide an idempotency key, or make retries safe. An output validation failure does not roll back effects already performed by the handler.auditCategorymetadata does not select a durable audit destination. Current operation auditing emits structured logs, not a durable audit store.exposure.mcpdoes not automatically install an MCP adapter or publish tools.- A successful response does not provide generic job execution, cancellation, progress storage, or background authorization revalidation. Plugins must define acceptance/progress/completion semantics for long-running work.
- Native enablement and interactive authorization do not secure every legacy mutation or automatic scheduler path. Do not copy those paths as the model for a new plugin.
Proposed compatibility and migration policy¶
The initial host contract is 0.1.0; coordinate compatible SDK packages and
contributors as one preview baseline. This is not a requirement that every
domain operation use the same version. OperationDescriptor.version records
the operation contract; it does not implement version negotiation or dispatch
multiple versions of the same operation ID.
The implemented manifest validator requires matching major and minor API
versions before 1.0.0 and checks minimumHostApiVersion. The proposed release
policy keeps 0.1.x changes compatible with the documented preview subset and
uses a new minor version for incompatible changes. Package publication and
release automation for that policy are not established by this document.
Before a breaking preview update, SDK maintainers and affected consuming teams should agree on the replacement API, migration instructions, required package and artifact rebuilds, and acceptance checks. Record the change in the changelog and affected author references. Do not silently remove a contract or promise an unimplemented compatibility window.
The verified development deployment baseline is RHDH next-2.1 with this
branch's platform dependencies, not a supported release matrix. The image tag
can change; record the tested image digest, SDK revision, package versions, and
dynamic artifacts for a reproducible acceptance run. Validation beyond this
baseline is not required for the current preview scope.
The shared SDK packages remain private workspace packages. Preview teams develop
their plugins in this monorepo using workspace:^ dependencies, the shared root
lockfile, and repository build/test tooling. Acceptance starts from a fresh
clone and includes dynamic export and installation in RHDH. Public npm packages,
an external repository starter, and out-of-tree onboarding are outside this
preview scope, not release prerequisites. A third-party plugin is architecturally
independent of the host even when its source lives in the same repository.
Contribution and experience model¶
A host experience represents a user goal, such as Content Quality. A plugin provides capabilities that help with that goal. One capability can have a card, tab, and action while reusing one backend operation.
Choose a Blueprint by the interaction, then declare applicability. Do not create a Blueprint per plugin or content type. Host layout, permissions and lifecycle are not plugin-owned DOM hooks.
The implemented manifest requires a known experienceId; this differs from the
proposed advisory recommendedExperienceId model. Do not invent experiences or
assume installing a plugin creates one.
Repository structure¶
Inside this repository, packages belong to the root Yarn workspace configuration
and lockfile. The four ownership groups are portal-core, self-service, aap,
and apme. They are not independent Yarn projects with separate lockfiles.
An illustrative contributor layout is:
workspaces/<contributor>/
└── plugins/
├── <plugin>-common/
│ └── src/ Shared domain types and operation descriptors
├── <frontend-plugin>/
│ ├── src/alpha.tsx Static native extension declarations
│ ├── src/manifest.ts Serializable capability metadata
│ ├── src/components/ Domain UI
│ ├── config.d.ts Configuration schema and visibility
│ └── package.json Role, exports, dependencies, export script
└── <backend-plugin-or-module>/
├── src/ Operation registration and domain handlers
├── config.d.ts
└── package.json
A frontend-only contribution does not require a backend. A shared domain package is useful when frontend and backend consume the same contracts. A scaffolder action can live in a separate backend module, as APME demonstrates.
Create preview plugins under the appropriate workspaces/<contributor>/plugins/
ownership group and declare SDK dependencies with workspace:^. A new ownership
group also requires root workspace/CI registration rather than only creating a
directory. Repository co-location does not permit imports of host internals or
another optional plugin's implementation. Out-of-tree development would require
a separately agreed dependency delivery model and is not part of this preview.
API entry points¶
| Import or service | Consumer | Responsibility |
|---|---|---|
@ansible/portal-extension-common |
Frontend and backend | Manifest, capability, launch, operation and subject contracts |
@ansible/portal-extension-api/alpha |
Contributor frontend | Native Blueprints and typed NFS descriptor/data-reference contracts |
portalOperationsApiRef from @ansible/portal-extension-api |
Contributor frontend | Execute a registered operation with subject and input |
portalEntitlementsApiRef from @ansible/portal-extension-api |
Host/frontend integration | Request host-resolved entitlement decisions |
@ansible/portal-plugin-sdk |
Contributor frontend | Shared UI, tokens, notifications, caching and utilities |
portalOperationsServiceRef from @ansible/portal-plugin-node |
Contributor backend | Register and execute operation handlers |
portalHealthServiceRef from @ansible/portal-plugin-node |
Contributor backend | Report health to the host registry |
portalActiveOrganizationServiceRef and portalEntitlementsServiceRef from @ansible/portal-plugin-node |
Backend integration | Resolve trusted organization memberships and entitlement decisions |
@ansible/portal-extension-host/alpha |
Host pages/composition | Install provider and native rendering adapters |
The host owns portalContributionsApiRef, validation, filtering and rendering.
Contributor declaration code normally uses the API library, not host rendering
internals. Host integration dependencies in contributor tests do not make the
contributor responsible for installing the provider.
Supported native interaction contracts¶
| Blueprint | Current purpose | Attachment or integration |
|---|---|---|
EntityDetailTabBlueprint |
Durable entity facet | entity.detail.tabs |
EntityOverviewCardBlueprint |
Compact entity summary | entity.overview.main or entity.overview.secondary |
EntityActionBlueprint |
Operation on the current entity | entity.actions |
CatalogItemActionBlueprint |
Operation from a catalog item | catalog.item.actions |
CatalogTabBlueprint |
Additional catalog view | catalog.tabs |
CatalogTableColumnBlueprint |
Comparable catalog value | catalog.table.columns |
CatalogHeaderActionBlueprint |
Catalog-level workflow entry | catalog.header.actions with registered workflow |
ScaffolderWorkflowBlueprint |
Workflow/template mapping | Namespaced workflow ID and template reference |
ScaffolderFieldBlueprint |
Typed custom template input | scaffolder.fields via shared provider and optional templates adapter |
For exact descriptor fields, use the exported types and the NFS composition guide. A union member in common types is not proof that every launch/surface combination has a renderer. Native entity actions currently dispatch operations; supported follow-ons select an eligible entity detail tab. Catalog-header workflows map to registered scaffolder templates, not arbitrary routes.
Field declarations and optional templates adapters¶
Declare native fields through ScaffolderFieldBlueprint using the existing
ScaffolderFieldDescriptor and defineScaffolderField contracts. The Blueprint
attaches metadata to Portal's shared contribution provider, not to a
portal-scaffolder page. The provider validates ownership, manifests, version
compatibility, and duplicate IDs/names without loading the widget.
An installed templates host uses createPortalScaffolderPage and
NativeScaffolderFields from @ansible/portal-extension-host/alpha to consume
accepted declarations and apply runtime gates before loading widgets. Field
names, value/UI schemas, component props, and validation functions are unchanged.
Without a templates consumer, declarations remain inspectable but unused and
widget loaders do not run. Declaring a field does not install a templates page
or make its workflow available.
Contributor code does not import the optional portal-scaffolder implementation or choose its page ID. Rebuild/re-export the host and contributors together, remove old templates-page attachment overrides, and follow the field attachment migration. No imperative module resolution, global registration fallback, or npm publication is required.
Declaring a native tab¶
Prerequisites¶
- A host app with the Portal contribution provider installed.
- Compatible Portal packages and the branch's platform dependency set. The
current frontend declarations use
@backstage/frontend-plugin-api0.17.1. - An entity detail page supplying canonical content type and entity context.
- An agreed plugin ID, host experience, and configuration key.
Procedure¶
- Create a serializable manifest. This minimal example displays existing subject metadata and declares no backend operation or entitlement:
// src/manifest.ts
import type { PluginManifest } from '@ansible/portal-extension-common';
export const manifest: PluginManifest = {
id: 'quality-insights',
version: '0.1.0',
apiVersion: '0.1.0',
entitlements: [],
capabilities: [
{
id: 'quality-insights.subject',
ownerPlugin: 'quality-insights',
experienceId: 'content-quality-assessment',
displayName: 'Subject details',
description: 'Show the selected repository identity.',
appliesToContentTypes: ['playbook-repository'],
minimumHostApiVersion: '0.1.0',
entryPoints: [
{
id: 'quality-insights.subject.tab',
kind: 'entity-tab',
surface: 'entity-page',
label: 'Subject details',
appliesToContentTypes: ['playbook-repository'],
launches: { type: 'slot', targetSlot: 'entity.detail.tabs' },
},
],
},
],
};
- Declare a static extension. Use a matching NFS plugin ID, manifest references, host attachment, and configuration gate:
// src/alpha.tsx
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
import { EntityDetailTabBlueprint } from '@ansible/portal-extension-api/alpha';
import { manifest } from './manifest';
export default createFrontendPlugin({
pluginId: 'quality-insights',
extensions: [
EntityDetailTabBlueprint.make({
name: 'subject',
params: {
manifest,
capabilityId: 'quality-insights.subject',
entryPointId: 'quality-insights.subject.tab',
attachment: 'entity.detail.tabs',
enabledByConfig: 'ansible.quality-insights.enabled',
loader: async ({ entity }) => {
const { SubjectTab } = await import('./components/SubjectTab');
return <SubjectTab name={entity.metadata.name} />;
},
},
}),
],
});
// src/components/SubjectTab.tsx
export function SubjectTab({ name }: { name: string }) {
return <p>Selected repository: {name}</p>;
}
-
Export
./alphafrom the frontend package, declare itsbackstage.role: frontend-plugin, and use compatible peer/build dependencies. Follow APME's checked-inpackage.jsonfor this branch's packaging pattern. Do not register native contributions through legacyindex.tsside effects. -
Install the frontend feature through the app composition root or dynamic package discovery. The consuming host plugin must not import the contributor implementation. Set the minimal runtime configuration:
Verification¶
Open an eligible repository and select Subject details. Disable the config key and confirm the contribution disappears. Confirm it does not load on an execution-environment page. Add integration tests for invalid manifests, permission/entitlement denial where declared, and contained loader failures.
This sample demonstrates declaration shape, not a complete workspace plugin. Sensitive domain data requires explicit permission and resource checks; the sample only displays metadata already supplied by the host page.
Adding a backend operation¶
Keep the operation descriptor in a shared domain package when both sides need
it. Use an owner-prefixed ID such as quality-insights.assessment.start, an operation
permission, input/output schemas, and appropriate exposure flags. The current
dispatcher requires a permission and REST exposure for execution.
Declare requiredEntitlements on the backend operation descriptor when the
operation depends on an entitlement. The dispatcher enforces every listed ID
before invoking the handler; a frontend declaration alone does not secure it.
Register the matching entitlement definitions through the injected
portalEntitlementsServiceRef. Missing definitions, absent grants, denied
permissions, unresolved organization, and inconsistent context deny execution.
Inject portalOperationsServiceRef into the backend feature and call
operations.register({ pluginId, descriptor, handler }) during initialization.
The handler receives OperationRequest and authenticated OperationContext.
Validate subject access and supported resource type before domain effects.
Frontend calls use this shape:
await operationsApi.execute('quality-insights.assessment.start', {
subject: { entityRef: 'component:default/example-repository' },
input: {},
});
Here operationsApi is the injected portalOperationsApiRef client. Pass the
host's actual current entity reference, not an arbitrary project ID or URL.
The transport returns { result }; long-running work should return status/job
information that the contributor can monitor without blocking the host page.
Use APME's catalog-backend-module-apme/src/operations.ts as a concrete reference
for registration and resource checks, not as a claim that all backend hardening
is finished. Its scan operation and interactive workflow routes enforce the
trusted organization, scan entitlement, and repository access. Registration and
deregistration do not require the scanning entitlement. Other legacy routes
and automatic background scans still need their own policy migration.
Use resolvePortalContext({ credentials, userInfo, organizations }) when
adapting another authenticated transport. Inject
portalActiveOrganizationServiceRef; do not infer organization from the user
namespace, SCM owner, submitted metadata, or a request header. The AAP adapter
verifies current direct/team memberships against configured organizations.
Resource handlers must still check catalog read permission, supported content
type, and domain-specific access.
Choose the backend operation scope explicitly:
- Omit
organizationScope, or use'organization', for organization-specific state. The dispatcher requires a single validatedorganizationId; multiple memberships are not an instruction to select the first. Scope persistence queries to that validated organization. - Use
organizationScope: 'shared'for operations on shared resources such as Git repositories. Any trusted membership with the required entitlement enables availability, but operation and resource RBAC still apply. The repository does not acquire an AAP organization owner.
For example, an operation descriptor for shared repository assessment includes:
import type { OperationDescriptor } from '@ansible/portal-extension-common';
import { createPermission } from '@backstage/plugin-permission-common';
export const startAssessment: OperationDescriptor = {
id: 'quality-insights.assessment.start',
version: '1.0.0',
organizationScope: 'shared',
requiredEntitlements: ['quality-insights.assessment'],
permission: createPermission({
name: 'quality-insights.assessment.start',
attributes: { action: 'create' },
}),
exposure: { rest: true },
inputSchema: { type: 'object', additionalProperties: false },
};
Backend context supplies organizationId for a single membership or
organizationIds for multiple memberships. There is no organization selector
in this SDK flow. For display, usePortalContext reads authenticated
GET /api/portal-operations/context; its result is not backend authority.
Configuration and packaging responsibilities¶
Separate package installation, native extension configuration, capability enablement, and authorization. Loading a package does not grant permission.
For new contributions, use ansible.<capability>.enabled and a shared config
reader across all capability artifacts. An absent enablement gate should disable
the capability. The implementation still has legacy/backend configuration paths requiring
conformance work; do not assume a native gate disables every backend route.
For an AAP deployment, a minimal entitlement policy is:
This assumes Engineering is configured in the existing AAP provider and the capability declares the matching entitlement. An expanded multi-organization example is:
ansible:
quality-insights:
enabled: true
catalog:
providers:
rhaap:
development:
orgs: [Engineering, Finance]
multiOrgEnabled: true
portal:
entitlements:
grants:
- id: quality-insights.assessment
organizations: [engineering, finance]
This expanded example is not required by the minimal tab above. It requires a
matching manifest entitlement definition and descriptor
requiredEntitlements: ['quality-insights.assessment']. Grants choose availability
for an organization; they are not user membership proof or replacements for RBAC.
Use the AAP adapter's canonical lowercase organization names in grants.
organizations: ['*'] grants availability across the caller's configured
memberships, not membership in every AAP organization. Deployment administrators
configure grants; plugin authors declare entitlement requirements.
Shared Git repositories remain in the catalog independently of these grants.
No catalog namespace mapping or ansible.io/portal-organization annotation is
required. Existing records are not renamed. See the
operations backend reference for the
generic single-organization fallback and identity compatibility details.
Use the repository's export-dynamic scripts and actual rhdh-cli embedding
options, rather than the historical Janus/Scalprum examples. Re-export consumers
when shared contracts change. Packaging compatibility has only been exercised
against next-2.1 in this work; supported version policy remains to be agreed.
Build SDK dependencies from the same repository revision and export the
resulting dynamic plugin artifacts for RHDH. This does not require npm
publication, a shared mutable registry singleton, or mount-point YAML.
Author acceptance checklist¶
- [ ] SDK and consuming-team reviewers agree on the preview subset and migration policy.
- [ ] The SDK revision, package versions, tested RHDH image digest, and dynamic artifacts are recorded.
- [ ] Static
./alphadeclarations match the manifest and actual plugin owner. - [ ] IDs, host API version, known experience, content scope and attachments are valid.
- [ ] Entry-point scope does not broaden capability scope.
- [ ] Predicates are executable frontend descriptors, not new serialized fields.
- [ ] Optional experiences and unsupported entities do not produce broken shells.
- [ ] Loading, empty, failure and restricted states are defined.
- [ ] New mutations use registered operations with permission and schema checks.
- [ ] Backend handlers authorize the selected resource independently of the UI.
- [ ] Backend operation entitlements are declared and registered, with denied-path tests.
- [ ] Operation scope distinguishes shared resources from organization-specific state and uses trusted memberships.
- [ ] Long-running operations distinguish acceptance, progress and completion.
- [ ] Disabled configuration and missing artifacts remove the intended capability.
- [ ] Other optional plugin implementations and private RHDH APIs are not dependencies.
- [ ] Health uses injected host services; audit/idempotency guarantees are stated honestly.
- [ ] Dynamic exports and the agreed platform version are verified.
- [ ] Tests cover enabled/disabled, matching/nonmatching, denied and failure cases.
Preview onboarding acceptance¶
Before declaring the in-repository preview ready for a consuming team, SDK maintainers and that team must complete the following from a fresh clone:
- Check out the agreed SDK revision, install dependencies from the repository
root with
./install-deps, and confirm the team's plugin is included in root workspace/CI configuration. Keep SDK dependencies asworkspace:^and use the shared root lockfile; no separately published SDK packages are required. - Run the repository checks below, build the required SDK and plugin packages, and export the plugin dynamically. Install it on the recorded RHDH baseline without importing its implementation into the host plugin.
- Verify a supported contribution and operation, including nonmatching content, disabled configuration, permission/entitlement denial, and contained failures.
- Record the results and resolve or explicitly exclude required unsupported behavior before the consuming team depends on it.
APME provides an in-tree integration reference. Each consuming team's plugin still needs fresh-clone acceptance; success in an existing developer checkout does not prove reproducibility. The checklist does not require npm publication or an external-plugin repository and does not claim this acceptance is complete.
Repository checks¶
From the root, run the repository's normal checks:
For focused native integration tests, use the yarn test --runInBand ...
command in the NFS composition guide.
Troubleshooting¶
Installed plugin has no contribution: check native feature discovery,
api:portal/contributions, manifest references, current subject scope,
configuration, experience, permissions and entitlements. Inspect host rejection
diagnostics before changing the loader.
Action is visible but execution fails: inspect registered operation ID, REST exposure, schemas, caller permission, handler resource checks and current organization behavior. Do not weaken authorization to bypass a denied decision.
Changes do not appear in RHDH: rebuild/re-export the affected consumers and follow the local deployment cache/restart procedure. Source app success does not prove a packed dynamic artifact was rebuilt.