Skip to content

Latest commit

 

History

History
242 lines (159 loc) · 19.6 KB

File metadata and controls

242 lines (159 loc) · 19.6 KB

Portal

Summary

The Portal subsystem is how a WordPress site learns the full shape of a product family: what tiers exist, what features are available at each tier, and how to acquire or install those features. Where Licensing tells the site "what does this key cover?", the Portal catalog tells the site "what does this product offer?"

The catalog data comes from the Commerce Portal API. It is not license-specific. It describes the complete product catalog regardless of what a particular key is entitled to. The intersection of catalog data and licensing data is what determines what a site can actually use.

Development status. The catalog structure described here represents the data we have identified that we need, not a finalized contract. The actual field names, tier slugs, tier names, and response shape are still being negotiated with the Portal team. Fixture data in tests/_data/catalog.json is a working prototype, not a spec.

What the Catalog Contains

Products

The catalog is organized by product. Kadence, GiveWP, LearnDash, and The Events Calendar are each a product. A product encompasses many features (plugins and themes) that customers can enable based on their tier.

Each product has an entry plugin that bootstraps Harbor on the site (see Products and Entry Plugins), but the product itself is the umbrella under which all of its features, tiers, and licensing live. A product catalog contains two things: tiers and features.

The product's own entry plugin is also returned as a feature within its catalog. For example, the kadence product includes a kadence feature of type theme representing Kadence itself. This means the update and feature management pipelines treat the product the same as any other feature — there is no special case for "the product itself" versus "add-on features."

Tiers

Each product defines an ordered set of tiers that represent subscription levels. Tiers are ranked, and a higher rank means a higher tier with more entitlements.

Field Type Description
slug string Unique identifier within the product (e.g., kadence-basic, kadence-pro)
name string Display name (e.g., "Basic", "Pro", "Agency")
rank int Numeric ordering value. Higher rank = higher tier
price int Price in the smallest currency unit (e.g., cents)
currency string Currency code (e.g., USD)
features string[] Marketing feature strings for this tier
herald_slugs string[] Herald slugs associated with this tier
purchase_url string Checkout URL to buy this tier; upgrades use a portal change-plan URL

Tiers are always sorted by rank. This ordering drives feature availability. A feature that requires kadence-pro (rank 2) is available to anyone on kadence-pro or kadence-agency (rank 3), but not to someone on kadence-basic (rank 1).

Products that have free offerings include a free tier at rank 0 (e.g., kadence-free). The free tier is the entry point to the tier hierarchy. Features gated at the free tier are available without a license key — an unlicensed user resolves to rank 0, and 0 >= 0 satisfies the availability check. The purchase_url on the free tier points to the first paid tier, providing the upgrade path.

A product's tiers are its own. Tier slugs are namespaced to the product (kadence-basic, give-basic) so there's no collision across product families.

Features

Features are the individual plugins and themes that make up a product family. Each feature belongs to one product and has a minimum tier requirement.

Field Type Description
slug string Unique identifier (e.g., kad-blocks-pro, ld-propanel)
kind string One of plugin or theme
minimum_tier string Tier slug required to access this feature
plugin_file string|null Plugin file path relative to plugins dir (e.g., kadence-blocks-pro/kadence-blocks-pro.php). Null for themes
wporg_slug string|null WordPress.org slug for plugins_api(). Non-null means the feature is on WordPress.org
version string|null Latest available version from the Commerce Portal
release_date string|null Release date of the latest version (ISO 8601)
changelog string|null Changelog HTML for the latest version, consistent with plugins_api() sections
name string Display name
description string Short description of what the feature does
category string Grouping category (e.g., blocks, theme, security, woocommerce)
authors string[]|null Product/author names. Null if not applicable.
documentation_url string Link to the feature's documentation
homepage string|null URL to the feature's homepage

Feature Types

Features come in two types, each representing a different kind of deliverable:

plugin: an installable WordPress plugin. Has a plugin_file (plugin file path) and either a Herald download URL (built at runtime for exclusive features) or is available on WordPress.org (wporg_slug is non-null). These are features that need to be downloaded, installed, and activated.

theme: an installable WordPress theme. The slug doubles as the theme stylesheet (directory name). Has either a Herald download URL (built at runtime for exclusive features) or is available on WordPress.org (wporg_slug is non-null).

Tier Gating

Every feature declares a minimum_tier. This is the lowest tier slug at which the feature becomes available. Because tiers are ranked, a feature available at kadence-pro (rank 2) is also available at kadence-agency (rank 3).

The catalog defines what tier a feature requires. Licensing defines what tier the customer is on. The intersection determines availability.

Caching and Data Access

Catalog Repository

The Catalog_Repository wraps the portal client with option-backed caching. The cache persists until explicitly invalidated. It is cleared automatically when the unified license key changes (lw-harbor/unified_license_key_changed), and can be force-refreshed via refresh().

Catalog_Repository::get()
├─ check option cache
├─ if hit → return cached Catalog_Collection
├─ if miss → Portal_Client::get_catalog()
├─ cache result (success or error, persisted)
└─ return Catalog_Collection|WP_Error

refresh() explicitly clears the cache and re-fetches. This is used when stale data needs to be invalidated immediately.

Both successful responses and errors are cached. An API error is stored to avoid hammering the API on repeated failures. The error throttle resets automatically on the next successful fetch.

Collections

The portal subsystem uses two typed collection classes:

Catalog_Collection holds Product_Catalog objects, keyed by product slug. This is what the repository returns. Lookups are by slug: $collection->get('kadence') returns the Kadence product catalog or null.

Tier_Collection holds Catalog_Tier objects within a product, keyed by tier slug. Tiers are automatically sorted by rank on construction.

Both collections prevent duplicate keys. Adding an item with an existing key returns the existing item without overwriting.

Naming Conventions

Class names in this subsystem follow a two-layer split:

  • Portal_* names belong to the transport layer — classes that represent the external service boundary (Portal_Client, Http_Client, Fixture_Client). Named for where the data comes from.
  • Catalog_* names belong to the data layer — classes that represent or store catalog data (Catalog_Repository, Catalog_Collection, Catalog_Feature, Catalog_Tier). Named for what the data is.

API Client

The Portal_Client contract defines the integration with the Commerce Portal API. Currently it exposes a single operation:

  • get_catalog(): Catalog_Collection|WP_Error: fetch the full product catalog.

Unlike the licensing client, this is not parameterized by key or domain. The catalog describes the full product universe. It is the same regardless of who is asking.

The Portal_Client interface is designed to accommodate future portal integrations beyond the catalog. Additional methods may be added as the portal exposes new data.

The production implementation is Clients\Http_Client, which uses the same PSR-18 HTTP infrastructure as the licensing client (see Licensing: HTTP Infrastructure). The base URL comes from Config::get_portal_base_url(). During development, the Clients\Fixture_Client is wired in. It reads a single JSON fixture file (tests/_data/catalog.json) containing all products. Tests use a fixture PSR-18 client that serves local JSON from tests/_data/catalog/.

Error Codes

Code Constant Meaning
lw-harbor-catalog-product-not-found PRODUCT_NOT_FOUND Requested product slug not in the catalog
lw-harbor-catalog-invalid-response INVALID_RESPONSE API response couldn't be parsed

Catalog Shape

The fixture data illustrates the structure. Each product in the current catalog follows a common pattern:

graph LR
    Product["Product: kadence"]

    Product --> Tiers
    Product --> Features["Features (33)"]

    subgraph Tiers
        T0["kadence-free\nrank 0, Free\npurchase_url → kadence-basic"]
        T1["kadence-basic\nrank 1, Basic"]
        T2["kadence-pro\nrank 2, Pro"]
        T3["kadence-agency\nrank 3, Agency"]
    end

    subgraph Features["Features (33)"]
        F1["kad-blocks\nplugin, min: kadence-free\ndot-org"]
        F2["kadence\ntheme, min: kadence-free\ndot-org"]
        F3["kad-blocks-pro\nplugin, min: kadence-basic\nexclusive"]
        F4["kad-shop-kit\nplugin, min: kadence-pro\nexclusive"]
        F5["kad-pattern-hub\nplugin, min: kadence-basic"]
        F6["..."]
    end
Loading

Note that kadence appears as both the product and as a feature within it. This is intentional — the product's entry point flows through the same update and feature management pipelines as any other feature.

The current fixture covers four product families:

Product Tiers Features Categories
kadence 4 (Free, Basic, Pro, Agency) 33 theme, blocks, design, woocommerce, forms, social, content, security, management, performance
learndash 3 (Basic, Pro, Agency) 8 core, membership, reporting, import, community
give 4 (Free, Basic, Pro, Agency) 28 core, forms, gateway, email, reporting, marketing, integration
the-events-calendar 4 (Free, Basic, Pro, Agency) 9 core, ticketing, community, integration

Relationship to Licensing and Features

What the Portal Provides to Feature Resolution

The portal catalog is one of two inputs to the Features layer. It contributes:

  1. The feature definitions, meaning every feature that exists within a product, with its type, minimum tier, installation metadata, and display information.
  2. The tier hierarchy, the ranked set of tiers that determines which features a given tier unlocks. The Resolve_Feature_Collection class looks up each feature's minimum_tier in the product's tier collection to get its rank, then compares against the customer's tier rank from Licensing.

Tier Slugs

Tier slugs are product-prefixed (kadence-pro, give-basic) and are consistent between the catalog and licensing responses. This means a tier value from a licensing Product_Entry can be looked up directly in the catalog's Tier_Collection without transformation.

Feature Type Mapping

The catalog uses delivery-oriented kind names (plugin, theme). The Features subsystem maps these to its own type hierarchy during resolution:

Catalog kind Feature class Meaning
plugin Plugin Installable WordPress plugin
theme Theme Installable WordPress theme

What the Portal Does Not Know

The catalog describes what exists. It does not know:

Question Answer comes from
What tier is the customer on? Licensing
Is this key valid? Licensing
Is a feature available to this customer? Features (joins catalog + licensing)
Is a feature currently enabled on this site? Features (checks local state)
What version is installed on this site? Features (reads from disk via Installable)

The catalog is the menu. Licensing is the receipt. Feature resolution is the waiter who checks both before serving.

Download URL Builder

Download URLs for exclusive (non-WordPress.org) features are not stored in the catalog response. They are built at runtime by an implementation of the Download_Url_Builder contract (Portal\Contracts\Download_Url_Builder). The contract is intentionally minimal — a single build( string $slug ): string method — so the download backend can be swapped without touching consumers.

Harbor ships three Herald implementations of the contract, each with a single, focused responsibility:

Class URL format Inputs
Herald_Url_Builder {base}/download/{slug}/latest/{license_key}/zip?site={domain} Unified key via Licensing\Repositories\License_Repository, domain via Site\Data
Herald_Legacy_Url_Builder {base}/legacy/download?plugin={slug}&key={legacy_key}&site={domain} Active legacy entry via Legacy\License_Repository::find(), domain via Site\Data
Herald_Routing_Url_Builder Delegates to one of the above The two builders above

Herald_Routing_Url_Builder is the implementation bound to Download_Url_Builder in the container. It tries the legacy builder first, then falls back to the Unified builder when legacy returns an empty string. Each underlying builder returns an empty string when it cannot satisfy the slug (no covering legacy entry, no Unified key, or no domain), so the routing class needs no licensing state of its own; the policy is simply "first non-empty wins."

Precedence. An active legacy license whose slug matches the requested feature wins over a stored Unified key. This is intentional and deliberately inverts the order used during feature resolution (see Features: Resolution, where Unified is the primary and Legacy is the fallback grant).

The two orders answer different questions:

  • Resolution asks "should this feature be shown as available at all?" Either source of entitlement is sufficient, and Unified is checked first because it is the canonical, modern source.
  • URL building asks "which key should authenticate the actual ZIP fetch?" Legacy keys are scoped to a specific slug via their slug field, so when a matching legacy entry exists, Harbor knows Herald will accept that key for that slug. The Unified key only authenticates features inside its capabilities array, and the URL builder does not consult licensing state to find out which features that includes. Preferring legacy when present therefore avoids generating Unified URLs that Herald would reject in mixed-entitlement scenarios (for example, a customer on a Unified tier that does not include a legacy add-on they still hold).

A legacy entry only contributes a URL when its is_active flag is true, its key is non-empty, and the reporting plugin has opted in with use_for_updates = true. The opt-in keeps Harbor from routing downloads for legacy keys whose backend is not Stellar Licensing v3 compatible. Otherwise Herald_Legacy_Url_Builder returns an empty string and the router falls through to Herald_Url_Builder.

The router returns an empty string when neither builder can produce a URL. That typically means the domain is empty, or that there is no matching active legacy key and no Unified key. The Herald base URL defaults to https://herald.stellarwp.com and is configurable via Config::set_herald_base_url().

To swap the download backend entirely (point at a different service, inject a test double), register a different binding for Download_Url_Builder::class.

Resolve_Update_Data depends on the contract and passes the resolved instance into Plugin::get_update_data() and Theme::get_update_data(), which call build() internally to populate the package field.

What the Portal Does Not Do

  • Know about license keys: the catalog is not parameterized by key. It describes what exists, not what a customer owns.
  • Track activation state: whether a feature is installed or active on a site is not a portal concern.
  • Change based on customer: every site sees the same catalog. Personalization happens by combining catalog data with licensing data.