Skip to content

Portal SDK architecture

The Portal SDK lets independently installed plugins contribute functionality to host-owned surfaces. Plugins declare capabilities and typed native frontend extensions; the host validates, filters, and renders them. Backend operations are registered separately so multiple UI surfaces can reuse the same handler.

This page describes the current implementation on the plugin-factory SDK branch. It is an experimental implementation, not a supported release or a claim that the complete Content Experience Architecture has shipped.

Host, experience, and contributor

  • Host: owns the supported interaction contracts, semantic attachment regions, context, validation, and presentation lifecycle.
  • Experience: a host-owned user goal such as Content Quality, Authoring, or Migration. A plugin is not an experience.
  • Contributor: supplies domain components, predicates, operation handlers, and plugin-owned persistence. APME is the current third-party example.
  • Capability: a plugin's functionality, with one or more entry points such as a summary card, detail tab, or scan action.

Self-service currently supplies the catalog and detail pages. It consumes host adapters without importing APME's implementation. Content browsing still awaits the planned content ownership split.

Package and runtime relationships

Arrows indicate contract consumption or runtime communication, as labeled. This is not an exhaustive package dependency graph or plugin load order.

flowchart TB
  Common["portal-extension-common<br/>Manifests, capabilities, operations, subjects"]

  subgraph Frontend["Frontend"]
    API["portal-extension-api<br/>Blueprints, API refs, clients"]
    SDK["portal-plugin-sdk<br/>Reusable UI and utilities"]
    Guest["Contributor frontend<br/>Static alpha declarations and components"]
    Core["portal-core<br/>Installs shared frontend APIs"]
    Host["portal-extension-host<br/>Validation, filtering, rendering"]
    Pages["Host surfaces<br/>Self-service catalogs and detail pages"]
  end

  subgraph Backend["Backend"]
    Node["portal-plugin-node<br/>Operation, identity, audit, health infrastructure"]
    GuestBE["Contributor backend<br/>Domain handlers and persistence"]
    Operations["portal-operations-backend<br/>Authenticated operation transport"]
    Health["portal-health-backend<br/>Aggregated health endpoint"]
  end

  Common -->|"Contract types"| API
  Common -->|"Contract types"| Node
  API -->|"Authoring contracts"| Guest
  SDK -->|"Shared UI"| Guest
  Guest -->|"Static NFS descriptors"| Host
  Core -->|"Installs contribution provider"| Host
  Host -->|"Eligible contributions"| Pages
  API -->|"Operation client requests"| Operations
  Node -->|"Injected host services"| GuestBE
  GuestBE -->|"Registers operation handlers"| Operations
  GuestBE -->|"Reports through injected health service"| Health

portal-core installs api:portal/contributions and the operation and entitlement API factories. Its navigation module selects discovered pages using configuration. It is not a Module Federation singleton registry provider.

The frontend and backend SDK packages are libraries, not standalone page plugins. Contributor backends inject shared host services instead of creating independent operation or health registries.

Native contribution lifecycle

flowchart LR
  Declare["Plugin alpha entry point<br/>Blueprint and manifest references"]
  Discover["NFS discovery<br/>Typed descriptors"]
  Validate["Validate<br/>Ownership, versions, references, region, scope"]
  Filter["Filter<br/>Content/source, experience/config,<br/>permission, entitlement, predicate"]
  Load["Load eligible component"]
  Render["Render within host<br/>error boundary and loading lifecycle"]
  Omit["Omit contribution<br/>Diagnostics for invalid declarations"]

  Declare --> Discover --> Validate --> Filter --> Load --> Render
  Validate -->|"Invalid"| Omit
  Filter -->|"Not applicable or denied"| Omit

Entity/catalog descriptors reach the shared provider through stable NFS data reference IDs. Pages read its validated snapshot via portalContributionsApiRef. Scaffolder fields have a separate native input on the templates page; not every Blueprint uses the entity/catalog provider input.

Static declarations load with the plugin. Component loaders remain deferred until eligibility checks pass; a tab component loads when that eligible tab is selected. Runtime predicates narrow the declared scope and cannot broaden it.

There is no native module-load registration into a global mutable ContributionRegistry. The explicit legacy app retains separate compatibility helpers; do not install both registration paths in the native app.

Operation execution

sequenceDiagram
  participant User
  participant UI as Host action
  participant Client as Portal operations client
  participant Host as Operations backend
  participant Handler as Contributor handler

  User->>UI: Select Scan
  UI->>Client: execute(operationId, subject, input)
  Client->>Host: Authenticated request
  Host->>Host: Resolve caller and trusted organization memberships
  Host->>Host: Check operation permission
  Host->>Host: Validate input schema
  Host->>Host: Enforce declared entitlements for the operation scope
  Host->>Handler: Execute registered handler with context
  Handler->>Handler: Resource checks and domain work
  Handler-->>Host: Result or job reference
  Host->>Host: Validate output and emit audit event
  Host-->>UI: Response
  UI->>UI: Supported follow-on navigation and refresh

The client discovers the portal-operations backend and sends an operation ID, the current subject's entity reference, and optional input. The manifest does not supply a handler URL. A descriptor alone does not install a handler: the backend registers it through portalOperationsServiceRef.

The dispatcher independently resolves trusted memberships, rejects an inconsistent context, and checks operation permission, declared entitlements, and input/output schemas. The handler remains responsible for resource access and domain checks. An accepted scan request is not a completed scan; job progress and findings belong to the contributor.

REST dispatch, identity middleware, repository registration adapters, and APME interactive workflows use the same injected organization resolver. Identity namespace is not organization identity. APME scan operations declare requiredEntitlements: ['apme.scanning']; workflow submission, approval, and state/event routes also enforce entitlement and repository access before forwarding traffic or resolving SCM credentials. State/event reads do not require the scan mutation permission.

The AAP adapter verifies the authenticated AAP identity, resolves current direct organization and team memberships, and intersects them with configured Portal organizations. Entitlement grants control availability, not membership. For a shared repository operation, any granted membership enables the capability; operation and resource permissions must still pass. For example, a user in Engineering and Finance can scan a shared repository when Engineering grants scanning, even if Finance does not.

PortalContext.organizationId is present only when one organization is unambiguous; organizationIds carries multiple trusted memberships without selecting the first. Operation descriptors default to organization-specific scope and require one validated organization. Descriptors explicitly declaring organizationScope: 'shared', including APME repository operations, can execute with multiple memberships. Shared access does not select a partition for organization-specific plugin data.

Git repositories have no mandatory AAP organization owner. New manual entries use the default catalog namespace. The former portal.organization.catalogMappings policy and ansible.io/portal-organization ownership checks are removed; existing records and annotations are not rewritten. Catalog namespace and SCM owner are not substitutes for membership or resource authorization.

The frontend usePortalContext hook reads authenticated host context from GET /api/portal-operations/context; it does not derive organization from the user namespace or fall back to default. The backend independently rechecks authorization rather than trusting browser context.

Important: Frontend filtering does not authorize backend resources. Remaining legacy mutations, automatic scanning policy, and organization selection are separate work; these checks do not introduce gateway tenancy or revoke running jobs.

Backend health reporting

flowchart LR
  Backend["Contributor backend"]
  Service["Injected portalHealthServiceRef"]
  Registry["Host health registry<br/>In-process state"]
  HTTP["portal-health-backend<br/>GET /api/portal-health/status"]

  Backend -->|"Push status"| Service --> Registry
  Registry -->|"Read snapshot"| HTTP

Health reporting is independent of contribution rendering. The endpoint aggregates plugin status; it is not a durable history store or a domain job tracker. A frontend health component exists in the host library, but the current deployment intentionally does not install a health dashboard or indicator.

Ownership and compatibility boundaries

  • RHDH loads exported plugin packages; Portal contracts define what their contributions mean. Platform adapters own discovery and authenticated fetch.
  • Plugins select supported semantic attachments, not CSS selectors or arbitrary runtime module URLs. Host pages own the layout.
  • Operations are separate from presentation. New server effects must use registered operations; remaining legacy mutation paths still need migration.
  • Common metadata must remain serializable. New native predicates belong on executable frontend descriptors, even though legacy contract fields remain.
  • The current manifest requires experienceId. The proposed advisory recommendation and richer host composition preview/activation/rollback model are not implemented by the generic attachments alone.

Delivered scope and remaining work

The implementation has native tabs, overview cards, entity/catalog actions, catalog tabs and columns, scaffolder workflow launchers, and native scaffolder fields. APME demonstrates Quality, activity, dependencies, scan actions, repository registration/removal, and background-result refresh.

Do not infer universal launch support from the common launch union. Native entity actions support operations and constrained entity-tab follow-ons; catalog-header workflows use registered scaffolder template descriptors. Other combinations require a supported host adapter.

Remaining work includes content extraction and agreed query/write contracts, authorization migration for remaining legacy routes and automatic background work, remaining operation guarantees, configuration/default-off conformance, published preview artifacts, and repeatable release acceptance. Audit emission currently means structured events, not a durable audit database; idempotent metadata does not provide a persisted idempotency-key mechanism.

Packed dynamic exports were exercised on next-2.1 with mock AAP authentication. Real AAP login and completed scan-only work were verified separately in the source NFS app. These are bounded acceptance checks, not broad platform or remediation compatibility guarantees.