[Core] Implement Kibana Pricing Tiers service (#221281)

## 📓 Summary

Closes https://github.com/elastic/observability-dev/issues/4490

We need to introduce a tier concept in Kibana to offer tiered versions
of our Observability solution.
Some time ago, the security team did something similar to introduce
tiered product lines, and this work lays the foundation for a Kibana
shared solution that can work with product tiers.

This PoC introduces a new core `PricingService` to manage feature
availability based on subscription tiers in serverless deployments. The
implementation enables:

- Tier-aware feature registration.
- Dynamic configuration loading based on
`serverless.<project-type>.<tier>.yml` files.
- Client/server APIs for feature checks based on available tier
(`isFeatureAvailable()`)
- Backwards compatibility with existing project types (Search,
Observability, Security, Chat)

## 🚶 Architecture walkthrough

### Configuration

To have a mechanism that allows for performing changes at the
configuration level, the `PricingService` loads and validates a strict
configuration of product tiers.

The available configuration is defined as follows:
```yml
# serverless.oblt.yml (default config)
pricing.tiers.enabled: true
pricing.tiers.products:
  - name: observability
    tier: complete

# serverless.oblt.complete.yml
xpack.infra.enabled: true
xpack.slo.enabled: true
xpack.features.overrides:
// Etc...

# serverless.oblt.essentials.yml
xpack.infra.enabled: false
xpack.slo.enabled: false
// Etc...

# Optionally, security solution could set their product tiers from their configuration files, as they are already supported by the core pricing service

# serverless.security.yml
pricing.tiers.enabled: true
pricing.tiers.products:
  - name: security
    tier: complete
  - name: endpoint
    tier: complete
  - name: cloud
    tier: complete
```

The Control Plane team will update how the kibana-controller inject the
configuration in the `kibana.yml` file to reflect the values set during
the project creation.

It will also auto-load any optional
`serverless.<project-type>.<tier>.yml` matching the config definition
when tiers are enabled.

The configuration is strictly validated against typed products. It might
be very opinionated to store this validation in a core package, but it
ensures strong typing across the codebase to register product features
and ensure a fast-failing check in case the product tiers do not exist.

### Pricing service

As far as the configuration-based approach is great to disable whole
plugins and override configs, it doesn't cover all the scenarios where a
specific tier wants to limit minor parts of a plugin feature (e.g., for
o11y limited onboarding, we cannot disable the whole plugin).

To cover these cases, the `PricingService` exposes a mechanism to
register features associated with specific tiers.
This is unbound from the `KibanaFeatureRegistry` mechanism, and we could
consider extending its behaviour in the future as needed. Once product
features are registered, the core start contract exposes a simple client
to detect when a feature is available.

<img width="1453" alt="Screenshot 2025-05-23 at 12 35 11"
src="https://github.com/user-attachments/assets/05267c00-afe0-49c6-b518-b1ce8f4a0546"
/>

## ✏️  Usage 

To launch Kibana with a specific product tier (e.g. observability
`essentials`, mostly limiting to logs features), update the
`pricing.tiers.products` in the serverless project configuration file:

```yml
# serverless.oblt.yml
pricing.tiers.enabled: true
pricing.tiers.products:
  - name: observability
    tier: essentials
```

The above will run a Kibana serverless project in this specific tier and
will load the configuration defined in `serverless.oblt.essentials.yml`,
which currently disables a couple of plugins.

Given this context, let's take a limited o11y onboarding when the
subscription tier is `essentials`:

```ts
// x-pack/solutions/observability/plugins/observability_onboarding/server/plugin.ts
export class ObservabilityOnboardingPlugin {
  public setup(core: CoreSetup) {
    // ...

    // Register a feature available only on the complete tier
    core.pricing.registerProductFeatures([
      {
        id: 'observability-complete-onboarding',
        products: [{ name: 'observability', tier: 'complete' }],
      },
    ]);

    // ...
  }
}

// x-pack/solutions/observability/plugins/observability_onboarding/public/application/onboarding_flow_form/onboarding_flow_form.tsx
const { core } = useKibana();

const isCompleteOnboardingAvailable = core.pricing.tiers.isFeatureAvailable('observability-complete-onboarding');

if (isCompleteOnboardingAvailable) {
  return <CompleteOnboarding />;
} else {
  return <EssentialsOnboarding />;
}
```

The results of the above changes will look as follows once Kibana is
running:

| Complete tier | Essentials tier |
|--------|--------|
| <img width="2998" alt="Screenshot 2025-05-23 at 13 51 14"
src="https://github.com/user-attachments/assets/bcf7c791-4623-42e4-91ce-0622d981e7e7"
/> | <img width="2996" alt="Screenshot 2025-05-23 at 13 53 36"
src="https://github.com/user-attachments/assets/429c82eb-761c-4aa1-b13d-81ac95301e60"
/> |

The tiers client is also available server-side through the
`getStartServices()` function, such that ad-hoc activity/registrations
can be performed.

## 👣 Next Steps  
- [x] Implement pending core tests
- [x] Document API usage in README

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marco Antonio Ghiani 2025-06-05 10:09:42 +02:00 committed by GitHub
parent ff9f47b22d
commit 657c0aa8b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
99 changed files with 2318 additions and 70 deletions

9
.github/CODEOWNERS vendored
View file

@ -238,6 +238,13 @@ src/core/packages/plugins/server-mocks @elastic/kibana-core
src/core/packages/preboot/server @elastic/kibana-core
src/core/packages/preboot/server-internal @elastic/kibana-core
src/core/packages/preboot/server-mocks @elastic/kibana-core
src/core/packages/pricing/browser @elastic/kibana-core
src/core/packages/pricing/browser-internal @elastic/kibana-core
src/core/packages/pricing/browser-mocks @elastic/kibana-core
src/core/packages/pricing/common @elastic/kibana-core
src/core/packages/pricing/server @elastic/kibana-core
src/core/packages/pricing/server-internal @elastic/kibana-core
src/core/packages/pricing/server-mocks @elastic/kibana-core
src/core/packages/rendering/browser @elastic/kibana-core
src/core/packages/rendering/browser-internal @elastic/kibana-core
src/core/packages/rendering/browser-mocks @elastic/kibana-core
@ -1913,6 +1920,8 @@ src/platform/plugins/shared/discover/public/context_awareness/profile_providers/
/config/serverless.chat.yml @elastic/kibana-core @elastic/kibana-security @elastic/search-kibana
/config/serverless.es.yml @elastic/kibana-core @elastic/kibana-security @elastic/search-kibana
/config/serverless.oblt.yml @elastic/kibana-core @elastic/kibana-security @elastic/observability-ui
/config/serverless.oblt.complete.yml @elastic/kibana-core @elastic/observability-ui
/config/serverless.oblt.essentials.yml @elastic/kibana-core @elastic/observability-ui
/config/serverless.security.yml @elastic/kibana-core @elastic/security-solution @elastic/kibana-security
/config/serverless.security.search_ai_lake.yml @elastic/security-solution @elastic/kibana-security
/config/serverless.security.essentials.yml @elastic/security-solution @elastic/kibana-security

2
.gitignore vendored
View file

@ -66,6 +66,8 @@ webpackstats.json
!/config/serverless.es.yml
!/config/serverless.chat.yml
!/config/serverless.oblt.yml
!/config/serverless.oblt.complete.yml
!/config/serverless.oblt.essentials.yml
!/config/serverless.security.yml
!/config/serverless.security.essentials.yml
!/config/serverless.security.complete.yml

View file

@ -0,0 +1,63 @@
# Observability Complete tier config
## Enabled plugins
xpack.infra.enabled: true
xpack.slo.enabled: true
xpack.features.overrides:
### Applications feature privileges are fine-tuned to grant access to Logs, and Observability apps.
apm:
### By default, this feature named as `APM and User Experience`, but should be renamed to `Applications`.
name: "Applications"
privileges:
# Infrastructure's `All` feature privilege should implicitly grant `All` access to Logs and Observability apps.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
- feature: "observability"
privileges: [ "all" ]
# Infrastructure's `Read` feature privilege should implicitly grant `Read` access to Logs and Observability apps.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
- feature: "observability"
privileges: [ "read" ]
### Fleet feature privileges are fine-tuned to grant access to Logs app.
fleetv2:
privileges:
# Fleet `All` feature privilege should implicitly grant `All` access to Logs app.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
# Fleet `Read` feature privilege should implicitly grant `Read` access to Logs app.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
infrastructure:
### By default, this feature named as `Metrics`, but should be renamed to `Infrastructure`.
name: "Infrastructure"
privileges:
# Infrastructure's `All` feature privilege should implicitly grant `All` access to Logs and Observability apps.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
- feature: "observability"
privileges: [ "all" ]
# Infrastructure's `Read` feature privilege should implicitly grant `Read` access to Logs and Observability apps.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
- feature: "observability"
privileges: [ "read" ]
### Logs feature is hidden in Role management since it's automatically granted by either Infrastructure, or Applications features.
logs.hidden: true
slo:
privileges:
# SLOs `All` feature privilege should implicitly grant `All` access to Observability app.
all.composedOf:
- feature: "observability"
privileges: [ "all" ]
# SLOs `Read` feature privilege should implicitly grant `Read` access to Observability app.
read.composedOf:
- feature: "observability"
privileges: [ "read" ]

View file

@ -0,0 +1,5 @@
# Observability Logs Essentials tier config
## Disable xpack plugins
xpack.infra.enabled: false
xpack.slo.enabled: false

View file

@ -1,34 +1,19 @@
# Observability Project config
## Core pricing tier for observability project
pricing.tiers.enabled: true
pricing.tiers.products:
- name: observability
tier: complete
# Make sure the plugins belonging to this project type are loaded
plugins.allowlistPluginGroups: ['platform', 'observability']
## Enabled plugins
xpack.infra.enabled: true
# Disabled Observability plugins
xpack.ux.enabled: false
xpack.legacy_uptime.enabled: false
## Fine-tune the observability solution feature privileges. Also, refer to `serverless.yml` for the project-agnostic overrides.
xpack.features.overrides:
### Applications feature privileges are fine-tuned to grant access to Logs, and Observability apps.
apm:
### By default, this feature named as `APM and User Experience`, but should be renamed to `Applications`.
name: "Applications"
privileges:
# Infrastructure's `All` feature privilege should implicitly grant `All` access to Logs and Observability apps.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
- feature: "observability"
privileges: [ "all" ]
# Infrastructure's `Read` feature privilege should implicitly grant `Read` access to Logs and Observability apps.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
- feature: "observability"
privileges: [ "read" ]
### Dashboards feature should be moved from Analytics category to the Observability one.
dashboard_v2.category: "observability"
### Discover feature should be moved from Analytics category to the Observability one and its privileges are
@ -54,36 +39,6 @@ xpack.features.overrides:
read.composedOf:
- feature: "observability"
privileges: [ "read" ]
### Fleet feature privileges are fine-tuned to grant access to Logs app.
fleetv2:
privileges:
# Fleet `All` feature privilege should implicitly grant `All` access to Logs app.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
# Fleet `Read` feature privilege should implicitly grant `Read` access to Logs app.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
### Infrastructure feature privileges are fine-tuned to grant access to Logs, and Observability apps.
infrastructure:
### By default, this feature named as `Metrics`, but should be renamed to `Infrastructure`.
name: "Infrastructure"
privileges:
# Infrastructure's `All` feature privilege should implicitly grant `All` access to Logs and Observability apps.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
- feature: "observability"
privileges: [ "all" ]
# Infrastructure's `Read` feature privilege should implicitly grant `Read` access to Logs and Observability apps.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
- feature: "observability"
privileges: [ "read" ]
### Logs feature is hidden in Role management since it's automatically granted by either Infrastructure, or Applications features.
logs.hidden: true
### Machine Learning feature should be moved from Analytics category to the Observability one and renamed to `AI Ops`.
ml:
category: "observability"
@ -91,17 +46,6 @@ xpack.features.overrides:
### Observability feature is hidden in Role management since it's automatically granted by either Discover,
### Infrastructure, Applications, Synthetics, or SLOs features.
observability.hidden: true
### SLOs feature privileges are fine-tuned to grant access to Observability app.
slo:
privileges:
# SLOs `All` feature privilege should implicitly grant `All` access to Observability app.
all.composedOf:
- feature: "observability"
privileges: [ "all" ]
# SLOs `Read` feature privilege should implicitly grant `Read` access to Observability app.
read.composedOf:
- feature: "observability"
privileges: [ "read" ]
### Stack alerts is hidden in Role management since it's not needed.
stackAlerts.hidden: true
### Synthetics feature privileges are fine-tuned to grant access to Observability app.
@ -118,8 +62,6 @@ xpack.features.overrides:
- feature: "observability"
privileges: [ "read" ]
## Enable the slo plugin
xpack.slo.enabled: true
## Cloud settings
xpack.cloud.serverless.project_type: observability
@ -127,8 +69,6 @@ xpack.cloud.serverless.project_type: observability
## Enable the Serverless Observability plugin
xpack.serverless.observability.enabled: true
## Configure plugins
## Set the home route
uiSettings.overrides.defaultRoute: /app/observability/landing

View file

@ -376,6 +376,11 @@
"@kbn/core-plugins-server-internal": "link:src/core/packages/plugins/server-internal",
"@kbn/core-preboot-server": "link:src/core/packages/preboot/server",
"@kbn/core-preboot-server-internal": "link:src/core/packages/preboot/server-internal",
"@kbn/core-pricing-browser": "link:src/core/packages/pricing/browser",
"@kbn/core-pricing-browser-internal": "link:src/core/packages/pricing/browser-internal",
"@kbn/core-pricing-common": "link:src/core/packages/pricing/common",
"@kbn/core-pricing-server": "link:src/core/packages/pricing/server",
"@kbn/core-pricing-server-internal": "link:src/core/packages/pricing/server-internal",
"@kbn/core-provider-plugin": "link:src/platform/test/plugin_functional/plugins/core_provider_plugin",
"@kbn/core-rendering-browser": "link:src/core/packages/rendering/browser",
"@kbn/core-rendering-browser-internal": "link:src/core/packages/rendering/browser-internal",
@ -1470,6 +1475,8 @@
"@kbn/core-plugins-browser-mocks": "link:src/core/packages/plugins/browser-mocks",
"@kbn/core-plugins-server-mocks": "link:src/core/packages/plugins/server-mocks",
"@kbn/core-preboot-server-mocks": "link:src/core/packages/preboot/server-mocks",
"@kbn/core-pricing-browser-mocks": "link:src/core/packages/pricing/browser-mocks",
"@kbn/core-pricing-server-mocks": "link:src/core/packages/pricing/server-mocks",
"@kbn/core-rendering-browser-mocks": "link:src/core/packages/rendering/browser-mocks",
"@kbn/core-rendering-server-mocks": "link:src/core/packages/rendering/server-mocks",
"@kbn/core-saved-objects-api-server-mocks": "link:src/core/packages/saved-objects/api-server-mocks",

View file

@ -73,6 +73,19 @@ export function compileConfigStack({
}
}
// Pricing specific tier configs
const config = getConfigFromFiles(configs.filter(isNotNull));
const isPricingTiersEnabled = _.get(config, 'pricing.tiers.enabled', false);
if (isPricingTiersEnabled) {
const tier = getServerlessProjectTierFromConfig(config);
if (tier) {
configs.push(resolveConfig(`serverless.${serverlessMode}.${tier}.yml`));
if (dev && devConfig !== false) {
configs.push(resolveConfig(`serverless.${serverlessMode}.${tier}.dev.yml`));
}
}
}
return configs.filter(isNotNull);
}
@ -100,6 +113,25 @@ function getSecurityTierFromCfg(configs) {
return productType?.product_tier;
}
/** @typedef {'essentials' | 'complete' | 'search_ai_lake' | 'ai_soc'} ServerlessProjectTier */
/**
* @param {string[]} config Configuration object from merged configs
* @returns {ServerlessProjectTier|undefined} The serverless project tier in the summed configs
*/
function getServerlessProjectTierFromConfig(config) {
const products = _.get(config, 'pricing.tiers.products', []);
// Constraint tier to be the same for
const uniqueTiers = _.uniqBy(products, 'tier');
if (uniqueTiers.length > 1) {
throw new Error(
'Multiple tiers found in pricing.tiers.products, the applied tier should be the same for all the products.'
);
}
return uniqueTiers.at(0)?.tier;
}
/**
* @param {string} fileName Name of the config within the config directory
* @returns {string | null} The resolved path to the config, if it exists, null otherwise

View file

@ -185,6 +185,128 @@ describe('compileConfigStack', () => {
});
});
describe('pricing tiers configuration', () => {
it('adds pricing tier config to the stack when pricing.tiers.enabled is true', async () => {
getConfigFromFiles.mockImplementationOnce(() => {
return {
pricing: {
tiers: {
enabled: true,
products: [{ name: 'observability', tier: 'essentials' }],
},
},
serverless: 'oblt',
};
});
const configList = compileConfigStack({
serverless: 'oblt',
}).map(toFileNames);
expect(configList).toEqual([
'serverless.yml',
'serverless.oblt.yml',
'kibana.yml',
'serverless.oblt.essentials.yml',
]);
});
it('adds pricing tier config with dev mode when pricing.tiers.enabled is true', async () => {
getConfigFromFiles.mockImplementationOnce(() => {
return {
pricing: {
tiers: {
enabled: true,
products: [{ name: 'observability', tier: 'complete' }],
},
},
serverless: 'oblt',
};
});
const configList = compileConfigStack({
serverless: 'oblt',
dev: true,
}).map(toFileNames);
expect(configList).toEqual([
'serverless.yml',
'serverless.oblt.yml',
'kibana.yml',
'kibana.dev.yml',
'serverless.dev.yml',
'serverless.oblt.dev.yml',
'serverless.oblt.complete.yml',
'serverless.oblt.complete.dev.yml',
]);
});
it('does not add pricing tier config when pricing.tiers.enabled is false', async () => {
getConfigFromFiles.mockImplementationOnce(() => {
return {
pricing: {
tiers: {
enabled: false,
products: [{ name: 'observability', tier: 'essentials' }],
},
},
serverless: 'oblt',
};
});
const configList = compileConfigStack({
serverless: 'oblt',
}).map(toFileNames);
expect(configList).toEqual(['serverless.yml', 'serverless.oblt.yml', 'kibana.yml']);
});
it('does not add pricing tier config when pricing.tiers.enabled is true but no tier is specified', async () => {
getConfigFromFiles.mockImplementationOnce(() => {
return {
pricing: {
tiers: {
enabled: true,
products: [],
},
},
serverless: 'oblt',
};
});
const configList = compileConfigStack({
serverless: 'oblt',
}).map(toFileNames);
expect(configList).toEqual(['serverless.yml', 'serverless.oblt.yml', 'kibana.yml']);
});
it('throws an error when multiple different tiers are specified', async () => {
getConfigFromFiles.mockImplementationOnce(() => {
return {
pricing: {
tiers: {
enabled: true,
products: [
{ name: 'observability', tier: 'complete' },
{ name: 'observability', tier: 'essentials' },
],
},
},
serverless: 'oblt',
};
});
expect(() => {
compileConfigStack({
serverless: 'oblt',
});
}).toThrow(
'Multiple tiers found in pricing.tiers.products, the applied tier should be the same for all the products.'
);
});
});
function toFileNames(path) {
return Path.basename(path);
}

View file

@ -97,6 +97,14 @@ export const createConfigService = ({
report_to: [],
});
}
if (path === 'pricing') {
return new BehaviorSubject({
tiers: {
enabled: true,
products: [],
},
});
}
throw new Error(`Unexpected config path: ${path}`);
});
return configService;

View file

@ -26,6 +26,7 @@ import { securityServiceMock } from '@kbn/core-security-browser-mocks';
import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks';
import { renderingServiceMock } from '@kbn/core-rendering-browser-mocks';
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-browser-mocks';
import { pricingServiceMock } from '@kbn/core-pricing-browser-mocks';
export function createCoreStartMock({ basePath = '' } = {}) {
const mock = {
@ -49,6 +50,7 @@ export function createCoreStartMock({ basePath = '' } = {}) {
security: securityServiceMock.createStart(),
userProfile: userProfileServiceMock.createStart(),
rendering: renderingServiceMock.create(),
pricing: pricingServiceMock.createStartContract(),
plugins: {
onStart: jest.fn(),
},

View file

@ -30,7 +30,8 @@
"@kbn/core-security-browser-mocks",
"@kbn/core-user-profile-browser-mocks",
"@kbn/core-feature-flags-browser-mocks",
"@kbn/core-rendering-browser-mocks"
"@kbn/core-rendering-browser-mocks",
"@kbn/core-pricing-browser-mocks"
],
"exclude": [
"target/**/*",

View file

@ -23,6 +23,7 @@ import type { ApplicationStart } from '@kbn/core-application-browser';
import type { ChromeStart } from '@kbn/core-chrome-browser';
import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser';
import type { PluginsServiceStart } from '@kbn/core-plugins-contracts-browser';
import type { PricingServiceStart } from '@kbn/core-pricing-browser';
import type { SecurityServiceStart } from '@kbn/core-security-browser';
import type { RenderingService } from '@kbn/core-rendering-browser';
import type { UserProfileServiceStart } from '@kbn/core-user-profile-browser';
@ -78,6 +79,8 @@ export interface CoreStart {
theme: ThemeServiceStart;
/** {@link PluginsServiceStart} */
plugins: PluginsServiceStart;
/** {@link PricingServiceStart} */
pricing: PricingServiceStart;
/** {@link SecurityServiceStart} */
security: SecurityServiceStart;
/** {@link UserProfileServiceStart} */

View file

@ -31,7 +31,8 @@
"@kbn/core-security-browser",
"@kbn/core-user-profile-browser",
"@kbn/core-feature-flags-browser",
"@kbn/core-rendering-browser"
"@kbn/core-rendering-browser",
"@kbn/core-pricing-browser"
],
"exclude": [
"target/**/*",

View file

@ -30,6 +30,7 @@ import type { InternalUserSettingsServiceSetup } from '@kbn/core-user-settings-s
import type { InternalSecurityServiceSetup } from '@kbn/core-security-server-internal';
import type { InternalUserProfileServiceSetup } from '@kbn/core-user-profile-server-internal';
import type { InternalFeatureFlagsSetup } from '@kbn/core-feature-flags-server-internal';
import type { PricingServiceSetup } from '@kbn/core-pricing-server';
/** @internal */
export interface InternalCoreSetup {
@ -56,4 +57,5 @@ export interface InternalCoreSetup {
userSettings: InternalUserSettingsServiceSetup;
security: InternalSecurityServiceSetup;
userProfile: InternalUserProfileServiceSetup;
pricing: PricingServiceSetup;
}

View file

@ -22,6 +22,7 @@ import type { CoreUsageDataStart } from '@kbn/core-usage-data-server';
import type { CustomBrandingStart } from '@kbn/core-custom-branding-server';
import type { InternalSecurityServiceStart } from '@kbn/core-security-server-internal';
import type { InternalUserProfileServiceStart } from '@kbn/core-user-profile-server-internal';
import type { PricingServiceStart } from '@kbn/core-pricing-server';
/**
* @internal
@ -42,4 +43,5 @@ export interface InternalCoreStart {
customBranding: CustomBrandingStart;
security: InternalSecurityServiceStart;
userProfile: InternalUserProfileServiceStart;
pricing: PricingServiceStart;
}

View file

@ -37,7 +37,8 @@
"@kbn/core-security-server-internal",
"@kbn/core-user-profile-server-internal",
"@kbn/core-feature-flags-server",
"@kbn/core-feature-flags-server-internal"
"@kbn/core-feature-flags-server-internal",
"@kbn/core-pricing-server"
],
"exclude": [
"target/**/*",

View file

@ -31,6 +31,7 @@ import { securityServiceMock } from '@kbn/core-security-server-mocks';
import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks';
import { createCoreStartMock } from './core_start.mock';
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks';
import { pricingServiceMock } from '@kbn/core-pricing-server-mocks';
type CoreSetupMockType = MockedKeys<CoreSetup> & {
elasticsearch: ReturnType<typeof elasticsearchServiceMock.createSetup>;
@ -83,6 +84,7 @@ export function createCoreSetupMock({
onSetup: jest.fn(),
onStart: jest.fn(),
},
pricing: pricingServiceMock.createSetupContract(),
getStartServices: jest
.fn<Promise<[ReturnType<typeof createCoreStartMock>, object, any]>, []>()
.mockResolvedValue([createCoreStartMock(), pluginStartDeps, pluginStartContract]),

View file

@ -23,6 +23,7 @@ import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mock
import { securityServiceMock } from '@kbn/core-security-server-mocks';
import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks';
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks';
import { pricingServiceMock } from '@kbn/core-pricing-server-mocks';
export function createCoreStartMock() {
const mock: MockedKeys<CoreStart> = {
@ -43,6 +44,7 @@ export function createCoreStartMock() {
plugins: {
onStart: jest.fn(),
},
pricing: pricingServiceMock.createStartContract(),
};
return mock;

View file

@ -30,6 +30,7 @@ import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks';
import { securityServiceMock } from '@kbn/core-security-server-mocks';
import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks';
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks';
import { pricingServiceMock } from '@kbn/core-pricing-server-mocks';
export function createInternalCoreSetupMock() {
const setupDeps = {
@ -56,6 +57,7 @@ export function createInternalCoreSetupMock() {
userSettings: userSettingsServiceMock.createSetupContract(),
security: securityServiceMock.createInternalSetup(),
userProfile: userProfileServiceMock.createInternalSetup(),
pricing: pricingServiceMock.createSetupContract(),
};
return setupDeps;
}

View file

@ -22,6 +22,7 @@ import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mock
import { securityServiceMock } from '@kbn/core-security-server-mocks';
import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks';
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks';
import { pricingServiceMock } from '@kbn/core-pricing-server-mocks';
export function createInternalCoreStartMock() {
const startDeps = {
@ -40,6 +41,7 @@ export function createInternalCoreStartMock() {
customBranding: customBrandingServiceMock.createStartContract(),
security: securityServiceMock.createInternalStart(),
userProfile: userProfileServiceMock.createInternalStart(),
pricing: pricingServiceMock.createStartContract(),
};
return startDeps;
}

View file

@ -38,6 +38,7 @@
"@kbn/core-security-server-mocks",
"@kbn/core-user-profile-server-mocks",
"@kbn/core-feature-flags-server-mocks",
"@kbn/core-pricing-server-mocks",
],
"exclude": [
"target/**/*",

View file

@ -27,6 +27,7 @@ import type { CoreUsageDataSetup } from '@kbn/core-usage-data-server';
import type { CustomBrandingSetup } from '@kbn/core-custom-branding-server';
import type { UserSettingsServiceSetup } from '@kbn/core-user-settings-server';
import type { PluginsServiceSetup } from '@kbn/core-plugins-contracts-server';
import type { PricingServiceSetup } from '@kbn/core-pricing-server';
import type { SecurityServiceSetup } from '@kbn/core-security-server';
import type { UserProfileServiceSetup } from '@kbn/core-user-profile-server';
import type { CoreStart } from './core_start';
@ -82,6 +83,8 @@ export interface CoreSetup<TPluginsStart extends Record<string, any> = {}, TStar
coreUsageData: CoreUsageDataSetup;
/** {@link PluginsServiceSetup} */
plugins: PluginsServiceSetup;
/** {@link PricingServiceSetup} */
pricing: PricingServiceSetup;
/** {@link SecurityServiceSetup} */
security: SecurityServiceSetup;
/** {@link UserProfileServiceSetup} */

View file

@ -22,6 +22,7 @@ import type { CustomBrandingStart } from '@kbn/core-custom-branding-server';
import type { PluginsServiceStart } from '@kbn/core-plugins-contracts-server';
import type { SecurityServiceStart } from '@kbn/core-security-server';
import type { UserProfileServiceStart } from '@kbn/core-user-profile-server';
import type { PricingServiceStart } from '@kbn/core-pricing-server';
/**
* Context passed to the plugins `start` method.
@ -55,6 +56,8 @@ export interface CoreStart {
coreUsageData: CoreUsageDataStart;
/** {@link PluginsServiceStart} */
plugins: PluginsServiceStart;
/** {@link PricingServiceStart} */
pricing: PricingServiceStart;
/** {@link SecurityServiceStart} */
security: SecurityServiceStart;
/** {@link UserProfileServiceStart} */

View file

@ -33,7 +33,8 @@
"@kbn/core-plugins-contracts-server",
"@kbn/core-security-server",
"@kbn/core-user-profile-server",
"@kbn/core-feature-flags-server"
"@kbn/core-feature-flags-server",
"@kbn/core-pricing-server"
],
"exclude": [
"target/**/*",

View file

@ -175,5 +175,6 @@ export function createPluginStartContext<
onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames),
},
rendering: deps.rendering,
pricing: deps.pricing,
};
}

View file

@ -291,6 +291,9 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>({
onSetup: (...dependencyNames) => runtimeResolver.onSetup(plugin.name, dependencyNames),
onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames),
},
pricing: {
registerProductFeatures: deps.pricing.registerProductFeatures,
},
security: {
registerSecurityDelegate: (api) => deps.security.registerSecurityDelegate(api),
fips: deps.security.fips,
@ -385,6 +388,7 @@ export function createPluginStartContext<TPlugin, TPluginDependencies>({
plugins: {
onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames),
},
pricing: deps.pricing,
security: {
authc: deps.security.authc,
audit: deps.security.audit,

View file

@ -0,0 +1,131 @@
# @kbn/core-pricing-browser-internal
## Overview
The `@kbn/core-pricing-browser-internal` package provides the browser-side implementation of Kibana's pricing service. It allows client-side code to check if features are available based on the current pricing tier configuration.
This package is part of Kibana's core and is designed to be consumed by other plugins that need to conditionally render UI elements or enable/disable functionality based on the pricing tier.
## Key Components
### PricingService
The main service that handles fetching pricing configuration from the server and providing a client to check feature availability.
### PricingTiersClient
A client that allows plugins to check if a specific feature is available based on the current pricing tier configuration.
## How It Works
The browser-side pricing service works by:
1. Fetching the pricing configuration from the server via an API call to `/internal/core/pricing`
2. Creating a `PricingTiersClient` instance with the fetched configuration
3. Providing the client to plugins through the core start contract
Plugins can then use the client to check if specific features are available based on the current pricing tier configuration.
## Usage
### Consuming the PricingService in a Plugin
To use the pricing service in your plugin, you can consume it directly from the provided `core` dependencies.
Here's an example of how to consume the pricing service in a plugin:
```typescript
// my-plugin/public/plugin.ts
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
export class MyPlugin implements Plugin {
public start(core: CoreStart) {
// Check if a feature is available based on the current pricing tier
const isFeature1Available = core.pricing.isFeatureAvailable('my-plugin:feature1');
const isFeature2Available = core.pricing.isFeatureAvailable('my-plugin:feature2');
// Conditionally enable features based on availability
if (isFeature1Available) {
// Enable feature1
}
if (isFeature2Available) {
// Enable feature2
}
}
}
```
### Using in React Components
To use the pricing service in React components, you'll typically want to access it through the CoreStart provided to a plugin.
#### Using with CoreStart
```tsx
// my-plugin/public/application.tsx
import React from 'react';
import { CoreStart } from '@kbn/core/public';
interface MyComponentProps {
coreStart: CoreStart;
}
const MyComponent: React.FC<MyComponentProps> = ({ coreStart }) => {
const isFeatureAvailable = coreStart.pricing.isFeatureAvailable('my-plugin:feature1');
return (
<div>
{isFeatureAvailable ? (
<div>This feature is available in your current pricing tier!</div>
) : (
<div>This feature requires an upgrade to access.</div>
)}
</div>
);
};
```
## Testing
When testing components that use the pricing service, you can use the `@kbn/core-pricing-browser-mocks` package to mock the pricing service:
```typescript
import { pricingServiceMock } from '@kbn/core-pricing-browser-mocks';
import { render } from '@testing-library/react';
import { CoreContext } from '@kbn/core-react';
import { MyComponent } from './my_component';
describe('MyComponent', () => {
let pricingStart: ReturnType<typeof pricingServiceMock.createStartContract>;
beforeEach(() => {
pricingStart = pricingServiceMock.createStartContract();
// Mock feature availability
pricingStart.isFeatureAvailable.mockImplementation((featureId) => {
if (featureId === 'my-plugin:feature1') {
return true;
}
return false;
});
});
it('renders feature1 when available', () => {
const { getByText } = render(
<CoreContext.Provider value={{ services: { pricing: pricingStart } }}>
<MyComponent />
</CoreContext.Provider>
);
expect(getByText('Use Feature 1')).toBeInTheDocument();
});
});
```
## Related Packages
- `@kbn/core-pricing-common`: Contains common types and interfaces used by both server and browser packages.
- `@kbn/core-pricing-browser`: The public API for the pricing service that plugins can consume.
- `@kbn/core-pricing-server`: The server-side counterpart to the pricing service.
- `@kbn/core-pricing-browser-mocks`: Mocks for testing plugins that consume the pricing service.

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { PricingService } from './src/pricing_service';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/src/core/packages/pricing/browser-internal'],
};

View file

@ -0,0 +1,9 @@
{
"type": "shared-browser",
"id": "@kbn/core-pricing-browser-internal",
"owner": [
"@elastic/kibana-core"
],
"group": "platform",
"visibility": "private"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/core-pricing-browser-internal",
"private": true,
"version": "1.0.0",
"author": "Kibana Core",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import type { GetPricingResponse } from '@kbn/core-pricing-browser';
import { PricingService } from './pricing_service';
describe('PricingService', () => {
let service: PricingService;
let http: ReturnType<typeof httpServiceMock.createStartContract>;
let mockPricingResponse: GetPricingResponse;
beforeEach(() => {
service = new PricingService();
http = httpServiceMock.createStartContract();
mockPricingResponse = {
tiers: {
enabled: true,
products: [
{ name: 'observability', tier: 'complete' },
{ name: 'security', tier: 'essentials' },
],
},
product_features: {
feature1: {
id: 'feature1',
description: 'A feature for observability products',
products: [{ name: 'observability', tier: 'complete' }],
},
feature2: {
id: 'feature2',
description: 'A feature for security products',
products: [{ name: 'security', tier: 'essentials' }],
},
},
};
http.get.mockResolvedValue(mockPricingResponse);
});
describe('#start()', () => {
it('fetches pricing data from the API', async () => {
await service.start({ http });
expect(http.get).toHaveBeenCalledWith('/internal/core/pricing');
});
it('returns a PricingTiersClient with the fetched data', async () => {
const startContract = await service.start({ http });
expect(startContract).toHaveProperty('isFeatureAvailable');
});
it('initializes the client with the correct tiers configuration', async () => {
const startContract = await service.start({ http });
// Since our mock has feature1 with observability product which is enabled in tiers
expect(startContract.isFeatureAvailable('feature1')).toBe(true);
});
it('initializes the client with empty data when API returns empty response', async () => {
const emptyResponse: GetPricingResponse = {
tiers: {
enabled: false,
products: [],
},
product_features: {},
};
http.get.mockResolvedValue(emptyResponse);
const startContract = await service.start({ http });
// When tiers are disabled, all features should be available
expect(startContract.isFeatureAvailable('any-feature')).toBe(true);
});
});
});

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { InternalHttpStart } from '@kbn/core-http-browser-internal';
import type { GetPricingResponse, PricingServiceStart } from '@kbn/core-pricing-browser';
import { PricingTiersClient, ProductFeaturesRegistry } from '@kbn/core-pricing-common';
interface StartDeps {
http: InternalHttpStart;
}
const defaultPricingResponse: GetPricingResponse = {
tiers: {
enabled: false,
products: [],
},
product_features: {},
};
/**
* Service that is responsible for UI Pricing.
* @internal
*/
export class PricingService {
public async start({ http }: StartDeps): Promise<PricingServiceStart> {
const isAnonymous = http.anonymousPaths.isAnonymous(window.location.pathname);
const pricingResponse = isAnonymous
? defaultPricingResponse
: await http.get<GetPricingResponse>('/internal/core/pricing');
const tiersClient = new PricingTiersClient(
pricingResponse.tiers,
new ProductFeaturesRegistry(pricingResponse.product_features)
);
return {
isFeatureAvailable: tiersClient.isFeatureAvailable,
};
}
}

View file

@ -0,0 +1,23 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*",
],
"kbn_references": [
"@kbn/core-http-browser-internal",
"@kbn/core-pricing-browser",
"@kbn/core-pricing-common",
"@kbn/core-http-browser-mocks",
]
}

View file

@ -0,0 +1,4 @@
# @kbn/core-pricing-browser-mocks
Contains the mocks for Core's internal `pricing` browser-side service:
- `pricingServiceMock`

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { pricingServiceMock } from './src/pricing_service.mock';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/src/core/packages/pricing/browser-mocks'],
};

View file

@ -0,0 +1,10 @@
{
"type": "shared-browser",
"id": "@kbn/core-pricing-browser-mocks",
"owner": [
"@elastic/kibana-core"
],
"group": "platform",
"visibility": "shared",
"devOnly": true
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/core-pricing-browser-mocks",
"private": true,
"version": "1.0.0",
"author": "Kibana Core",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { PricingServiceStart } from '@kbn/core-pricing-browser';
import type { PricingService } from '@kbn/core-pricing-browser-internal';
const createStartContractMock = (): jest.Mocked<PricingServiceStart> => ({
isFeatureAvailable: jest.fn(),
});
const createMock = (): jest.Mocked<PublicMethodsOf<PricingService>> => ({
start: jest.fn().mockImplementation(createStartContractMock),
});
export const pricingServiceMock = {
create: createMock,
createStartContract: createStartContractMock,
};

View file

@ -0,0 +1,22 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*",
],
"kbn_references": [
"@kbn/utility-types",
"@kbn/core-pricing-browser",
"@kbn/core-pricing-browser-internal",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/core-pricing-browser
This package contains the public types for Core's browser-side `pricing` service.

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { PricingServiceStart } from './src/contracts';
export type { GetPricingResponse } from './src/api';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/src/core/packages/pricing/browser'],
};

View file

@ -0,0 +1,9 @@
{
"type": "shared-browser",
"id": "@kbn/core-pricing-browser",
"owner": [
"@elastic/kibana-core"
],
"group": "platform",
"visibility": "shared"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/core-pricing-browser",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { PricingProduct, PricingProductFeature } from '@kbn/core-pricing-common';
export interface GetPricingResponse {
tiers: {
enabled: boolean;
products: PricingProduct[];
};
product_features: Record<string, PricingProductFeature>;
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { IPricingTiersClient } from '@kbn/core-pricing-common';
/**
* Start contract for Core's pricing service.
*
* @public
*/
export interface PricingServiceStart {
isFeatureAvailable: IPricingTiersClient['isFeatureAvailable'];
}

View file

@ -0,0 +1,21 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core-pricing-common",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/core-pricing-common
Contains public types and constants for Core's browser-side `pricing` service.

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { ProductFeaturesRegistry } from './src/product_features_registry';
export type { IPricingTiersClient, PricingProductFeature } from './src/types';
export type { PricingProduct, TiersConfig } from './src/pricing_tiers_config';
export { PricingTiersClient } from './src/pricing_tiers_client';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/src/core/packages/pricing/common'],
};

View file

@ -0,0 +1,9 @@
{
"type": "shared-common",
"id": "@kbn/core-pricing-common",
"owner": [
"@elastic/kibana-core"
],
"group": "platform",
"visibility": "shared"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/core-pricing-common",
"private": true,
"version": "1.0.0",
"author": "Kibana Core",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { PricingTiersClient } from './pricing_tiers_client';
import { ProductFeaturesRegistry } from './product_features_registry';
import type { PricingProductFeature } from './types';
import type { TiersConfig } from './pricing_tiers_config';
describe('PricingTiersClient', () => {
let productFeaturesRegistry: ProductFeaturesRegistry;
let tiersConfig: TiersConfig;
let client: PricingTiersClient;
beforeEach(() => {
productFeaturesRegistry = new ProductFeaturesRegistry();
});
describe('isFeatureAvailable', () => {
describe('when tiers are disabled', () => {
beforeEach(() => {
tiersConfig = {
enabled: false,
products: undefined,
};
client = new PricingTiersClient(tiersConfig, productFeaturesRegistry);
});
it('returns true for any feature, even if it does not exist', () => {
expect(client.isFeatureAvailable('non-existent-feature')).toBe(true);
});
it('returns true for registered features indipendently of the tier configuration', () => {
const feature: PricingProductFeature = {
id: 'test-feature',
description: 'A test feature for observability',
products: [{ name: 'observability', tier: 'complete' }],
};
productFeaturesRegistry.register(feature);
expect(client.isFeatureAvailable('test-feature')).toBe(true);
});
});
describe('when tiers are enabled', () => {
beforeEach(() => {
tiersConfig = {
enabled: true,
products: [
{ name: 'observability', tier: 'complete' },
{ name: 'security', tier: 'essentials' },
],
};
client = new PricingTiersClient(tiersConfig, productFeaturesRegistry);
});
it('returns false for non-existent features', () => {
expect(client.isFeatureAvailable('non-existent-feature')).toBe(false);
});
it('returns true when a feature has a matching active product', () => {
const feature: PricingProductFeature = {
id: 'observability-feature',
description: 'A feature for observability products',
products: [{ name: 'observability', tier: 'complete' }],
};
productFeaturesRegistry.register(feature);
expect(client.isFeatureAvailable('observability-feature')).toBe(true);
});
it('returns false when a feature has no matching active products', () => {
const feature: PricingProductFeature = {
id: 'cloud-feature',
description: 'A feature for cloud products',
products: [{ name: 'cloud', tier: 'complete' }],
};
productFeaturesRegistry.register(feature);
expect(client.isFeatureAvailable('cloud-feature')).toBe(false);
});
it('returns true when at least one product in a feature matches an active product', () => {
const feature: PricingProductFeature = {
id: 'mixed-feature',
description: 'A feature available in multiple products',
products: [
{ name: 'cloud', tier: 'complete' },
{ name: 'security', tier: 'essentials' },
],
};
productFeaturesRegistry.register(feature);
expect(client.isFeatureAvailable('mixed-feature')).toBe(true);
});
it('checks for exact product matches including tier', () => {
const feature: PricingProductFeature = {
id: 'tier-mismatch-feature',
description: 'A feature with tier requirements',
products: [{ name: 'security', tier: 'complete' }], // Note: tier is 'complete' but active product has 'essentials'
};
productFeaturesRegistry.register(feature);
expect(client.isFeatureAvailable('tier-mismatch-feature')).toBe(false);
});
});
});
});

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { isEqual } from 'lodash';
import { IPricingTiersClient } from './types';
import { PricingProduct, TiersConfig } from './pricing_tiers_config';
import { ProductFeaturesRegistry } from './product_features_registry';
/**
* Client implementation for checking feature availability based on pricing tiers.
*
* This client evaluates whether features are available based on the current pricing tier configuration
* and the registered product features.
*
* @public
*/
export class PricingTiersClient implements IPricingTiersClient {
/**
* Creates a new PricingTiersClient instance.
*
* @param tiers - The current pricing tiers configuration
* @param productFeaturesRegistry - Registry containing the available product features
*/
constructor(
private readonly tiers: TiersConfig,
private readonly productFeaturesRegistry: ProductFeaturesRegistry
) {}
/**
* Checks if a product is active in the current pricing tier configuration.
*
* @param product - The product to check
* @returns True if the product is active, false otherwise
* @private
*/
private isActiveProduct = (product: PricingProduct) => {
return Boolean(this.tiers.products?.some((currentProduct) => isEqual(currentProduct, product)));
};
/**
* Checks if pricing tiers are enabled in the current configuration.
*
* @returns True if pricing tiers are enabled, false otherwise
* @private
*/
private isEnabled = () => {
return this.tiers.enabled;
};
/**
* Determines if a feature is available based on the current pricing tier configuration.
* When pricing tiers are disabled, all features are considered available.
* When pricing tiers are enabled, a feature is available if it's associated with at least one active product.
*
* @param featureId - The identifier of the feature to check
* @returns True if the feature is available in the current pricing tier, false otherwise
*/
isFeatureAvailable = <TFeatureId extends string>(featureId: TFeatureId): boolean => {
/**
* We assume that when the pricing tiers are disabled, features are available globally
* and not constrained by any product tier.
*/
if (!this.isEnabled()) {
return true;
}
const feature = this.productFeaturesRegistry.get(featureId);
if (feature) {
return feature.products.some((product) => this.isActiveProduct(product));
}
return false;
};
}

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { TypeOf, offeringBasedSchema, schema } from '@kbn/config-schema';
/**
* Schema defining the valid pricing product configurations.
* Each product has a name and an associated tier that determines feature availability.
*
* @internal
*/
export const pricingProductsSchema = schema.oneOf([
schema.object({
name: schema.literal('observability'),
tier: schema.oneOf([schema.literal('complete'), schema.literal('essentials')]),
}),
schema.object({
name: schema.literal('ai_soc'),
tier: schema.literal('search_ai_lake'),
}),
schema.object({
name: schema.literal('security'),
tier: schema.oneOf([
schema.literal('complete'),
schema.literal('essentials'),
schema.literal('search_ai_lake'),
]),
}),
schema.object({
name: schema.literal('endpoint'),
tier: schema.oneOf([
schema.literal('complete'),
schema.literal('essentials'),
schema.literal('search_ai_lake'),
]),
}),
schema.object({
name: schema.literal('cloud'),
tier: schema.oneOf([
schema.literal('complete'),
schema.literal('essentials'),
schema.literal('search_ai_lake'),
]),
}),
]);
/**
* Represents a product with an associated pricing tier.
* Used to determine feature availability based on the current pricing configuration.
*
* @public
*/
export type PricingProduct = TypeOf<typeof pricingProductsSchema>;
/**
* Schema defining the pricing tiers configuration structure.
* Includes whether tiers are enabled and which products are active.
*
* @internal
*/
export const tiersConfigSchema = schema.object({
enabled: offeringBasedSchema({
serverless: schema.boolean({ defaultValue: false }),
traditional: schema.literal(false),
options: { defaultValue: false },
}),
products: schema.maybe(schema.arrayOf(pricingProductsSchema)),
});
/**
* Configuration for pricing tiers that determines feature availability.
* When enabled, features are only available if they're associated with an active product.
* When disabled, all features are considered available.
*
* @public
*/
export type TiersConfig = TypeOf<typeof tiersConfigSchema>;

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { PricingProductFeature } from './types';
/**
* Registry for managing pricing product features.
* Provides methods to register, retrieve, and manage features that are available in specific pricing tiers.
*
* @public
*/
export class ProductFeaturesRegistry {
/**
* Internal storage for registered product features.
* @private
*/
private readonly productFeatures: Map<string, PricingProductFeature>;
/**
* Creates a new ProductFeaturesRegistry instance.
*
* @param initialFeatures - Optional initial set of features to populate the registry
*/
constructor(initialFeatures: Record<string, PricingProductFeature> = {}) {
this.productFeatures = new Map(Object.entries(initialFeatures));
}
/**
* Retrieves a product feature by its ID.
*
* @param featureId - The ID of the feature to retrieve
* @returns The product feature if found, undefined otherwise
*/
get(featureId: string): PricingProductFeature | undefined {
return this.productFeatures.get(featureId);
}
/**
* Registers a new product feature in the registry.
* Throws an error if a feature with the same ID is already registered.
*
* @param feature - The product feature to register
* @throws Error if a feature with the same ID is already registered
*/
register(feature: PricingProductFeature) {
if (this.productFeatures.has(feature.id)) {
throw new Error(
`A product feature with id "${feature.id}" is already registered, please change id or check whether is the same feature.`
);
}
this.productFeatures.set(feature.id, feature);
}
/**
* Converts the registry to a plain JavaScript object.
*
* @returns A record mapping feature IDs to their corresponding feature objects
*/
asObject(): Record<string, PricingProductFeature> {
return Object.fromEntries(this.productFeatures);
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { PricingProduct } from './pricing_tiers_config';
/**
* Represents a feature that is registered for specific pricing tiers.
*
* @public
*/
export interface PricingProductFeature {
/* Unique identifier for the feature. */
id: string;
/* Human-readable description of the feature. */
description: string;
/* List of products and tiers where this feature is available. */
products: PricingProduct[];
}
/**
* Client interface for checking feature availability based on pricing tiers.
*
* @public
*/
export interface IPricingTiersClient {
/**
* Determines if a feature is available based on the current pricing tier configuration.
*
* @param featureId - The identifier of the feature to check
* @returns True if the feature is available in the current pricing tier, false otherwise
*/
isFeatureAvailable<TFeatureId extends string>(featureId: TFeatureId): boolean;
}

View file

@ -0,0 +1,20 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"kbn_references": [
"@kbn/config-schema",
],
"exclude": [
"target/**/*",
]
}

View file

@ -0,0 +1,192 @@
# @kbn/core-pricing-server-internal
## Overview
The `@kbn/core-pricing-server-internal` package provides a service for managing pricing tiers and feature availability in Kibana. It allows you to define different product tiers (e.g., "essentials" vs "complete") and control which features are available in each tier.
This package is part of Kibana's core and is designed to be consumed by other plugins that need to check if certain features should be available based on the current pricing tier configuration.
## Key Components
### PricingService
The main service that handles the initialization, configuration, and management of pricing tiers. It provides methods for registering product features and checking feature availability.
### PricingTiersClient
A client that allows plugins to check if a specific feature is available based on the current pricing tier configuration.
### ProductFeaturesRegistry
A registry that stores information about which features are available in which product tiers.
## Configuration
The pricing service is configured through the `pricing` configuration path in Kibana's configuration. The configuration schema is defined as follows:
```typescript
export const pricingConfig = {
path: 'pricing',
schema: schema.object({
tiers: schema.object({
enabled: schema.boolean({ defaultValue: false }),
products: schema.maybe(schema.arrayOf(pricingProductsSchema)),
}),
}),
};
```
Where `pricingProductsSchema` defines the available products and tiers:
```typescript
export const pricingProductsSchema = schema.oneOf([
schema.object({
name: schema.literal('observability'),
tier: schema.oneOf([schema.literal('complete'), schema.literal('essentials')]),
}),
// More available products defined by any solution
]);
```
## Usage
### Consuming the PricingService in a Plugin
To use the pricing service in your plugin, you can consume it directly from the provided `core` dependencies.
Here's an example of how to consume the pricing service in a plugin:
```typescript
// my-plugin/server/plugin.ts
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/server';
import { PricingServiceSetup, PricingServiceStart } from '@kbn/core-pricing-server';
interface MyPluginSetupDeps {
pricing: PricingServiceSetup;
}
interface MyPluginStartDeps {
pricing: PricingServiceStart;
}
export class MyPlugin implements Plugin {
public setup(core: CoreSetup, { pricing }: MyPluginSetupDeps) {
// Register features that your plugin provides
pricing.registerProductFeatures([
{
id: 'my-plugin:feature1',
description: 'A feature for observability products',
products: [
{ name: 'observability', tier: 'complete' },
],
},
{
id: 'my-plugin:feature2',
description: 'A feature for security products',
products: [
{ name: 'security', tier: 'essentials' },
],
},
]);
}
public start(core: CoreStart, { pricing }: MyPluginStartDeps) {
// Check if a feature is available based on the current pricing tier
const isFeature1Available = pricing.isFeatureAvailable('my-plugin:feature1');
const isFeature2Available = pricing.isFeatureAvailable('my-plugin:feature2');
// Conditionally enable features based on availability
if (isFeature1Available) {
// Enable feature1
}
if (isFeature2Available) {
// Enable feature2
}
}
}
```
### API Endpoints
The pricing service exposes an internal API endpoint that returns the current pricing tiers configuration and registered product features:
```
GET /internal/core/pricing
```
Response format:
```json
{
"tiers": {
"enabled": true,
"products": [
{ "name": "observability", "tier": "complete" },
{ "name": "security", "tier": "essentials" }
]
},
"product_features": {
"my-plugin:feature1": {
"id": "my-plugin:feature1",
"products": [
{ "name": "observability", "tier": "complete" },
]
},
"my-plugin:feature2": {
"id": "my-plugin:feature2",
"products": [
{ "name": "observability", "tier": "essentials" },
]
}
}
}
```
## Best Practices
1. **Feature IDs**: Use a consistent naming convention for feature IDs, such as `pluginId:featureName`.
2. **Granular Features**: Define features at a granular level to allow for fine-grained control over which features are available in which tiers.
3. **Feature Documentation**: Document which features are available in which tiers to help users understand the differences between tiers.
## Testing
When testing your plugin with the pricing service, you can use the `@kbn/core-pricing-server-mocks` package to mock the pricing service:
```typescript
import { pricingServiceMock } from '@kbn/core-pricing-server-mocks';
describe('My Plugin', () => {
let pricingSetup: ReturnType<typeof pricingServiceMock.createSetupContract>;
let pricingStart: ReturnType<typeof pricingServiceMock.createStartContract>;
beforeEach(() => {
pricingSetup = pricingServiceMock.createSetupContract();
pricingStart = pricingServiceMock.createStartContract();
// Mock feature availability
pricingStart.isFeatureAvailable.mockImplementation((featureId) => {
if (featureId === 'my-plugin:feature1') {
return true;
}
if (featureId === 'my-plugin:feature2') {
return false;
}
return false;
});
});
it('should enable feature1 when available', () => {
// Test your plugin with the mocked pricing service
});
});
```
## Related Packages
- `@kbn/core-pricing-common`: Contains common types and interfaces used by both server and browser packages.
- `@kbn/core-pricing-server`: The public API for the pricing service that plugins can consume.
- `@kbn/core-pricing-browser`: The browser-side counterpart to the pricing service.
- `@kbn/core-pricing-server-mocks`: Mocks for testing plugins that consume the pricing service.

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { PricingConfigType } from './src/pricing_config';
export { pricingConfig } from './src/pricing_config';
export { PricingService } from './src/pricing_service';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../../..',
roots: ['<rootDir>/src/core/packages/pricing/server-internal'],
};

View file

@ -0,0 +1,9 @@
{
"type": "shared-server",
"id": "@kbn/core-pricing-server-internal",
"owner": [
"@elastic/kibana-core"
],
"group": "platform",
"visibility": "private"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/core-pricing-server-internal",
"private": true,
"version": "1.0.0",
"author": "Kibana Core",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { schema, TypeOf } from '@kbn/config-schema';
import { tiersConfigSchema } from '@kbn/core-pricing-common/src/pricing_tiers_config';
export const pricingConfig = {
path: 'pricing',
schema: schema.object({
tiers: tiersConfigSchema,
}),
};
export type PricingConfigType = TypeOf<typeof pricingConfig.schema>;

View file

@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { mockCoreContext } from '@kbn/core-base-server-mocks';
import { mockRouter, RouterMock } from '@kbn/core-http-router-server-mocks';
import {
httpServiceMock,
InternalHttpServicePrebootMock,
InternalHttpServiceSetupMock,
} from '@kbn/core-http-server-mocks';
import { of } from 'rxjs';
import { PricingService } from './pricing_service';
import type { PricingConfigType } from './pricing_config';
import type { PricingProductFeature } from '@kbn/core-pricing-common';
describe('PricingService', () => {
let prebootHttp: InternalHttpServicePrebootMock;
let setupHttp: InternalHttpServiceSetupMock;
let service: PricingService;
let router: RouterMock;
let mockConfig: PricingConfigType;
beforeEach(() => {
prebootHttp = httpServiceMock.createInternalPrebootContract();
setupHttp = httpServiceMock.createInternalSetupContract();
router = mockRouter.create();
setupHttp.createRouter.mockReturnValue(router);
mockConfig = {
tiers: {
enabled: true,
products: [
{ name: 'observability', tier: 'complete' },
{ name: 'security', tier: 'essentials' },
],
},
};
const coreContext = mockCoreContext.create();
// Mock the config service to return our test config
coreContext.configService.atPath.mockReturnValue(of(mockConfig));
service = new PricingService(coreContext);
});
describe('#preboot()', () => {
it('registers the pricing routes with default config', () => {
service.preboot({ http: prebootHttp });
// Preboot uses default config, not the loaded config
const expectedDefaultConfig = { tiers: { enabled: false, products: [] } };
expect((service as any).pricingConfig).toEqual(expectedDefaultConfig);
// Verify that routes are registered on the preboot HTTP service
expect(prebootHttp.registerRoutes).toHaveBeenCalledWith('', expect.any(Function));
});
});
describe('#setup()', () => {
it('registers the pricing routes', async () => {
await service.preboot({ http: prebootHttp });
await service.setup({ http: setupHttp });
expect(setupHttp.createRouter).toHaveBeenCalledWith('');
expect(router.get).toHaveBeenCalledTimes(1);
expect(router.get).toHaveBeenCalledWith(
expect.objectContaining({
path: '/internal/core/pricing',
security: expect.objectContaining({
authz: expect.objectContaining({
enabled: false,
}),
}),
}),
expect.any(Function)
);
});
it('allows registering product features', async () => {
await service.preboot({ http: prebootHttp });
const setup = await service.setup({ http: setupHttp });
const mockFeatures: PricingProductFeature[] = [
{
id: 'feature1',
description: 'A feature',
products: [{ name: 'observability', tier: 'complete' }],
},
{
id: 'feature2',
description: 'Another feature',
products: [{ name: 'security', tier: 'essentials' }],
},
];
setup.registerProductFeatures(mockFeatures);
// Verify the service has the features registered
const registry = (service as any).productFeaturesRegistry;
expect(registry.get('feature1')).toBeDefined();
expect(registry.get('feature2')).toBeDefined();
});
});
describe('#start()', () => {
it('returns a PricingTiersClient with the configured tiers', async () => {
await service.preboot({ http: prebootHttp });
await service.setup({ http: setupHttp });
const start = service.start();
expect(start).toHaveProperty('isFeatureAvailable');
});
it('returns a PricingTiersClient that can check feature availability', async () => {
await service.preboot({ http: prebootHttp });
const setup = await service.setup({ http: setupHttp });
const mockFeatures: PricingProductFeature[] = [
{
id: 'feature1',
description: 'A feature',
products: [{ name: 'observability', tier: 'complete' }],
},
];
setup.registerProductFeatures(mockFeatures);
const start = service.start();
// Since our mock config has observability product enabled, this feature should be available
expect(start.isFeatureAvailable('feature1')).toBe(true);
});
});
});

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { CoreContext } from '@kbn/core-base-server-internal';
import type { Logger } from '@kbn/logging';
import { firstValueFrom } from 'rxjs';
import type { IConfigService } from '@kbn/config';
import {
type PricingProductFeature,
ProductFeaturesRegistry,
PricingTiersClient,
} from '@kbn/core-pricing-common';
import type {
InternalHttpServicePreboot,
InternalHttpServiceSetup,
} from '@kbn/core-http-server-internal';
import type { PricingConfigType } from './pricing_config';
import { registerRoutes } from './routes';
interface PrebootDeps {
http: InternalHttpServicePreboot;
}
interface SetupDeps {
http: InternalHttpServiceSetup;
}
/** @internal */
export class PricingService {
private readonly configService: IConfigService;
private readonly logger: Logger;
private readonly productFeaturesRegistry: ProductFeaturesRegistry;
private pricingConfig: PricingConfigType;
constructor(core: CoreContext) {
this.logger = core.logger.get('pricing-service');
this.configService = core.configService;
this.productFeaturesRegistry = new ProductFeaturesRegistry();
this.pricingConfig = { tiers: { enabled: false, products: [] } };
}
public preboot({ http }: PrebootDeps) {
this.logger.debug('Prebooting pricing service');
// The preboot server has no need for real pricing.
http.registerRoutes('', (router) => {
registerRoutes(router, {
pricingConfig: this.pricingConfig,
productFeaturesRegistry: this.productFeaturesRegistry,
});
});
}
public async setup({ http }: SetupDeps) {
this.logger.debug('Setting up pricing service');
this.pricingConfig = await firstValueFrom(
this.configService.atPath<PricingConfigType>('pricing')
);
registerRoutes(http.createRouter(''), {
pricingConfig: this.pricingConfig,
productFeaturesRegistry: this.productFeaturesRegistry,
});
return {
registerProductFeatures: (features: PricingProductFeature[]) => {
features.forEach((feature) => {
this.productFeaturesRegistry.register(feature);
});
},
};
}
public start() {
const tiersClient = new PricingTiersClient(
this.pricingConfig.tiers,
this.productFeaturesRegistry
);
return {
isFeatureAvailable: tiersClient.isFeatureAvailable,
};
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { IRouter } from '@kbn/core-http-server';
import type { ProductFeaturesRegistry } from '@kbn/core-pricing-common';
import { registerPricingRoutes } from './pricing';
import type { PricingConfigType } from '../pricing_config';
export function registerRoutes(
router: IRouter,
params: {
pricingConfig: PricingConfigType;
productFeaturesRegistry: ProductFeaturesRegistry;
}
) {
registerPricingRoutes(router, params);
}

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { IRouter } from '@kbn/core-http-server';
import type { ProductFeaturesRegistry } from '@kbn/core-pricing-common';
import type { PricingConfigType } from '../pricing_config';
export function registerPricingRoutes(
router: IRouter,
params: {
pricingConfig: PricingConfigType;
productFeaturesRegistry: ProductFeaturesRegistry;
}
) {
router.get(
{
path: '/internal/core/pricing',
security: {
authz: {
enabled: false,
reason:
'Pricing information does not require authorization and should be always accessible.',
},
},
options: { access: 'internal' },
validate: false,
},
async (_context, _req, res) => {
return res.ok({
body: {
tiers: params.pricingConfig.tiers,
product_features: params.productFeaturesRegistry.asObject(),
},
});
}
);
}

View file

@ -0,0 +1,28 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"kbn_references": [
"@kbn/config",
"@kbn/config-schema",
"@kbn/core-pricing-common",
"@kbn/core-http-server",
"@kbn/core-base-server-internal",
"@kbn/logging",
"@kbn/core-http-server-internal",
"@kbn/core-base-server-mocks",
"@kbn/core-http-router-server-mocks",
"@kbn/core-http-server-mocks",
],
"exclude": [
"target/**/*",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/core-pricing-server-mocks
Empty package generated by @kbn/generate

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { pricingServiceMock } from './src/pricing_service.mock';
export type { PricingServiceContract } from './src/pricing_service.mock';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../../..',
roots: ['<rootDir>/src/core/packages/pricing/server-mocks'],
};

View file

@ -0,0 +1,10 @@
{
"type": "shared-server",
"id": "@kbn/core-pricing-server-mocks",
"owner": [
"@elastic/kibana-core"
],
"group": "platform",
"visibility": "shared",
"devOnly": true
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/core-pricing-server-mocks",
"private": true,
"version": "1.0.0",
"author": "Kibana Core",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { PricingServiceSetup, PricingServiceStart } from '@kbn/core-pricing-server';
import type { PricingService } from '@kbn/core-pricing-server-internal';
const createSetupContractMock = () => {
const setupContract: jest.Mocked<PricingServiceSetup> = {
registerProductFeatures: jest.fn(),
};
return setupContract;
};
const createStartContractMock = () => {
const startContract: jest.Mocked<PricingServiceStart> = {
isFeatureAvailable: jest.fn(),
};
return startContract;
};
export type PricingServiceContract = PublicMethodsOf<PricingService>;
const createMock = () => {
const mocked: jest.Mocked<PricingServiceContract> = {
preboot: jest.fn(),
setup: jest.fn().mockReturnValue(createSetupContractMock()),
start: jest.fn().mockReturnValue(createStartContractMock()),
};
return mocked;
};
export const pricingServiceMock = {
create: createMock,
createSetupContract: createSetupContractMock,
createStartContract: createStartContractMock,
};

View file

@ -0,0 +1,21 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts"
],
"exclude": [
"target/**/*",
],
"kbn_references": [
"@kbn/utility-types",
"@kbn/core-pricing-server",
"@kbn/core-pricing-server-internal",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/core-pricing-browser
This package contains the public types for Core's server-side pricing service.

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { PricingServiceSetup, PricingServiceStart } from './src/contracts';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/src/core/packages/pricing/server'],
};

View file

@ -0,0 +1,9 @@
{
"type": "shared-server",
"id": "@kbn/core-pricing-server",
"owner": [
"@elastic/kibana-core"
],
"group": "platform",
"visibility": "shared"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/core-pricing-server",
"private": true,
"version": "1.0.0",
"author": "Kibana Core",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { IPricingTiersClient, PricingProductFeature } from '@kbn/core-pricing-common';
/**
* APIs to manage pricing product features during the setup phase.
*
* Plugins that want to register features that are available in specific pricing tiers
* should use the `registerProductFeatures` method during the setup phase.
*
* @public
*/
export interface PricingServiceSetup {
/**
* Register product features that are available in specific pricing tiers.
*
* @example
* ```ts
* // my-plugin/server/plugin.ts
* public setup(core: CoreSetup) {
* core.pricing.registerProductFeatures([
* {
* id: 'my_premium_feature',
* description: 'A premium feature only available in specific tiers',
* products: [{ name: 'security', tier: 'complete' }]
* }
* ]);
* }
* ```
*/
registerProductFeatures(features: PricingProductFeature[]): void;
}
/**
* APIs to access pricing tier information during the start phase.
*
* @public
*/
export interface PricingServiceStart {
/**
* Check if a specific feature is available based on the current pricing tier configuration.
* Delegates to the underlying {@link IPricingTiersClient.isFeatureAvailable} implementation.
*
* @example
* ```ts
* // my-plugin/server/plugin.ts
* public start(core: CoreStart) {
* const isPremiumFeatureAvailable = core.pricing.isFeatureAvailable('my_premium_feature');
* // Use the availability information to enable/disable functionality
* }
* ```
*/
isFeatureAvailable: IPricingTiersClient['isFeatureAvailable'];
}

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts"
],
"exclude": [
"target/**/*",
],
"kbn_references": [
"@kbn/core-pricing-common",
]
}

View file

@ -28,6 +28,7 @@ import { loggingSystemMock } from '@kbn/core-logging-browser-mocks';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks';
import { securityServiceMock } from '@kbn/core-security-browser-mocks';
import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks';
import { pricingServiceMock } from '@kbn/core-pricing-browser-mocks';
export const analyticsServiceStartMock = analyticsServiceMock.createAnalyticsServiceStart();
export const MockAnalyticsService = analyticsServiceMock.create();
@ -173,3 +174,9 @@ export const UserProfileServiceConstructor = jest
jest.doMock('@kbn/core-user-profile-browser-internal', () => ({
UserProfileService: UserProfileServiceConstructor,
}));
export const MockPricingService = pricingServiceMock.create();
export const PricingServiceConstructor = jest.fn().mockImplementation(() => MockPricingService);
jest.doMock('@kbn/core-pricing-browser-internal', () => ({
PricingService: PricingServiceConstructor,
}));

View file

@ -49,6 +49,8 @@ import {
SecurityServiceConstructor,
MockUserProfileService,
UserProfileServiceConstructor,
MockPricingService,
PricingServiceConstructor,
} from './core_system.test.mocks';
import type { EnvironmentMode } from '@kbn/config';
import { CoreSystem } from './core_system';
@ -157,6 +159,7 @@ describe('constructor', () => {
expect(CustomBrandingServiceConstructor).toHaveBeenCalledTimes(1);
expect(SecurityServiceConstructor).toHaveBeenCalledTimes(1);
expect(UserProfileServiceConstructor).toHaveBeenCalledTimes(1);
expect(PricingServiceConstructor).toHaveBeenCalledTimes(1);
});
it('passes injectedMetadata param to InjectedMetadataService', () => {
@ -520,6 +523,11 @@ describe('#start()', () => {
await startCore();
expect(MockUserProfileService.start).toHaveBeenCalledTimes(1);
});
it('calls pricing#start()', async () => {
await startCore();
expect(MockPricingService.start).toHaveBeenCalledTimes(1);
});
});
describe('#stop()', () => {

View file

@ -38,6 +38,7 @@ import { RenderingService } from '@kbn/core-rendering-browser-internal';
import { CoreAppsService } from '@kbn/core-apps-browser-internal';
import type { InternalCoreSetup, InternalCoreStart } from '@kbn/core-lifecycle-browser-internal';
import { PluginsService } from '@kbn/core-plugins-browser-internal';
import { PricingService } from '@kbn/core-pricing-browser-internal';
import { CustomBrandingService } from '@kbn/core-custom-branding-browser-internal';
import { SecurityService } from '@kbn/core-security-browser-internal';
import { UserProfileService } from '@kbn/core-user-profile-browser-internal';
@ -111,6 +112,7 @@ export class CoreSystem {
private readonly customBranding: CustomBrandingService;
private readonly security: SecurityService;
private readonly userProfile: UserProfileService;
private readonly pricing: PricingService;
private fatalErrorsSetup: FatalErrorsSetup | null = null;
constructor(params: CoreSystemParams) {
@ -166,6 +168,7 @@ export class CoreSystem {
this.deprecations = new DeprecationsService();
this.executionContext = new ExecutionContextService();
this.plugins = new PluginsService(this.coreContext, injectedMetadata.uiPlugins);
this.pricing = new PricingService();
this.coreApp = new CoreAppsService(this.coreContext);
this.customBranding = new CustomBrandingService();
@ -385,6 +388,7 @@ export class CoreSystem {
});
const featureFlags = await this.featureFlags.start();
const pricing = await this.pricing.start({ http });
const core: InternalCoreStart = {
analytics,
@ -408,6 +412,7 @@ export class CoreSystem {
security,
userProfile,
rendering,
pricing,
};
await this.plugins.start(core);

View file

@ -69,6 +69,8 @@
"@kbn/core-injected-metadata-common-internal",
"@kbn/core-feature-flags-browser-internal",
"@kbn/react-mute-legacy-root-warning",
"@kbn/core-pricing-browser-internal",
"@kbn/core-pricing-browser-mocks",
],
"exclude": [
"target/**/*",

View file

@ -34,6 +34,7 @@ import { statusConfig } from '@kbn/core-status-server-internal';
import { uiSettingsConfig } from '@kbn/core-ui-settings-server-internal';
import { config as pluginsConfig } from '@kbn/core-plugins-server-internal';
import { featureFlagsConfig } from '@kbn/core-feature-flags-server-internal';
import { pricingConfig } from '@kbn/core-pricing-server-internal';
import { elasticApmConfig } from './root/elastic_config';
import { serverlessConfig } from './root/serverless_config';
import { coreConfig } from './core_config';
@ -59,6 +60,7 @@ export function registerServiceConfig(configService: ConfigService) {
pathConfig,
pidConfig,
pluginsConfig,
pricingConfig,
savedObjectsConfig,
savedObjectsMigrationConfig,
serverlessConfig,

View file

@ -56,6 +56,7 @@ import { DiscoveredPlugins, PluginsService } from '@kbn/core-plugins-server-inte
import { CoreAppsService } from '@kbn/core-apps-server-internal';
import { SecurityService } from '@kbn/core-security-server-internal';
import { UserProfileService } from '@kbn/core-user-profile-server-internal';
import { PricingService } from '@kbn/core-pricing-server-internal';
import { registerServiceConfig } from './register_service_config';
import { MIGRATION_EXCEPTION_CODE } from './constants';
import { coreConfig, type CoreConfigType } from './core_config';
@ -91,6 +92,7 @@ export class Server {
private readonly deprecations: DeprecationsService;
private readonly executionContext: ExecutionContextService;
private readonly prebootService: PrebootService;
private readonly pricing: PricingService;
private readonly docLinks: DocLinksService;
private readonly customBranding: CustomBrandingService;
private readonly userSettingsService: UserSettingsService;
@ -143,6 +145,7 @@ export class Server {
this.deprecations = new DeprecationsService(core);
this.executionContext = new ExecutionContextService(core);
this.prebootService = new PrebootService(core);
this.pricing = new PricingService(core);
this.docLinks = new DocLinksService(core);
this.customBranding = new CustomBrandingService(core);
this.userSettingsService = new UserSettingsService(core);
@ -206,6 +209,8 @@ export class Server {
this.capabilities.preboot({ http: httpPreboot });
this.pricing.preboot({ http: httpPreboot });
const elasticsearchServicePreboot = await this.elasticsearch.preboot();
await this.status.preboot({ http: httpPreboot });
@ -359,6 +364,8 @@ export class Server {
rendering: renderingSetup,
});
const pricingSetup = await this.pricing.setup({ http: httpSetup });
const coreSetup: InternalCoreSetup = {
analytics: analyticsSetup,
capabilities: capabilitiesSetup,
@ -380,6 +387,7 @@ export class Server {
metrics: metricsSetup,
deprecations: deprecationsSetup,
coreUsageData: coreUsageDataSetup,
pricing: pricingSetup,
userSettings: userSettingsServiceSetup,
security: securitySetup,
userProfile: userProfileSetup,
@ -451,6 +459,8 @@ export class Server {
const featureFlagsStart = this.featureFlags.start();
const pricingStart = this.pricing.start();
this.httpRateLimiter.start();
this.status.start();
@ -474,6 +484,7 @@ export class Server {
deprecations: deprecationsStart,
security: securityStart,
userProfile: userProfileStart,
pricing: pricingStart,
};
this.coreApp.start(this.coreStart);

View file

@ -79,6 +79,7 @@
"@kbn/core-feature-flags-server-internal",
"@kbn/core-http-rate-limiter-internal",
"@kbn/projects-solutions-groups",
"@kbn/core-pricing-server-internal",
],
"exclude": [
"target/**/*",

View file

@ -281,6 +281,8 @@ export type {
ErrorToastOptions,
} from '@kbn/core-notifications-browser';
export type { PricingServiceStart } from '@kbn/core-pricing-browser';
export type { ToastsApi } from '@kbn/core-notifications-browser-internal';
export type { CustomBrandingStart, CustomBrandingSetup } from '@kbn/core-custom-branding-browser';

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
// TODO replace the line below with
// preset: '@kbn/test/jest_integration_node
// to do so, we must fix all integration tests first
// see https://github.com/elastic/kibana/pull/130255/
preset: '@kbn/test/jest_integration',
rootDir: '../../../../..',
roots: ['<rootDir>/src/core/server/integration_tests/pricing'],
// must override to match all test given there is no `integration_tests` subfolder
testMatch: ['**/*.test.{js,mjs,ts,tsx}'],
};

View file

@ -0,0 +1,144 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import supertest from 'supertest';
import { REPO_ROOT } from '@kbn/repo-info';
import { Env } from '@kbn/config';
import { getEnvOptions } from '@kbn/config-mocks';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks';
import { contextServiceMock } from '@kbn/core-http-context-server-mocks';
import {
HttpService,
InternalHttpServicePreboot,
InternalHttpServiceSetup,
} from '@kbn/core-http-server-internal';
import { createConfigService, createHttpService } from '@kbn/core-http-server-mocks';
import { PricingService } from '@kbn/core-pricing-server-internal';
import type { PricingProductFeature, PricingProduct } from '@kbn/core-pricing-common';
const coreId = Symbol('core');
const env = Env.createDefault(REPO_ROOT, getEnvOptions());
const configService = createConfigService();
describe('PricingService', () => {
let server: HttpService;
let httpPreboot: InternalHttpServicePreboot;
let httpSetup: InternalHttpServiceSetup;
let service: PricingService;
let serviceSetup: Awaited<ReturnType<PricingService['setup']>>;
beforeEach(async () => {
server = createHttpService();
httpPreboot = await server.preboot({ context: contextServiceMock.createPrebootContract() });
httpSetup = await server.setup({
context: contextServiceMock.createSetupContract(),
executionContext: executionContextServiceMock.createInternalSetupContract(),
});
service = new PricingService({
coreId,
env,
logger: loggingSystemMock.create(),
configService,
});
await service.preboot({ http: httpPreboot });
serviceSetup = await service.setup({ http: httpSetup });
await server.start();
});
afterEach(async () => {
await server.stop();
});
describe('/internal/core/pricing route', () => {
it('is exposed and returns pricing configuration', async () => {
const result = await supertest(httpSetup.server.listener)
.get('/internal/core/pricing')
.expect(200);
expect(result.body).toHaveProperty('tiers');
expect(result.body).toHaveProperty('product_features');
expect(typeof result.body.tiers).toBe('object');
expect(typeof result.body.product_features).toBe('object');
});
it('returns default pricing configuration when no custom config is provided', async () => {
const result = await supertest(httpSetup.server.listener)
.get('/internal/core/pricing')
.expect(200);
expect(result.body).toMatchInlineSnapshot(`
Object {
"product_features": Object {},
"tiers": Object {
"enabled": true,
"products": Array [],
},
}
`);
});
it('includes registered product features in the response', async () => {
// Register a product feature
const testFeature: PricingProductFeature = {
id: 'test_feature',
description: 'A test feature for integration testing',
products: [
{ name: 'observability', tier: 'complete' },
{ name: 'security', tier: 'essentials' },
] as PricingProduct[],
};
serviceSetup.registerProductFeatures([testFeature]);
const result = await supertest(httpSetup.server.listener)
.get('/internal/core/pricing')
.expect(200);
expect(result.body.product_features).toHaveProperty('test_feature');
expect(result.body.product_features.test_feature).toEqual(testFeature);
});
it('handles multiple registered product features', async () => {
const feature1: PricingProductFeature = {
id: 'feature_1',
description: 'First test feature',
products: [
{ name: 'observability', tier: 'complete' },
{ name: 'security', tier: 'essentials' },
] as PricingProduct[],
};
const feature2: PricingProductFeature = {
id: 'feature_2',
description: 'Second test feature',
products: [
{
name: 'security',
tier: 'complete',
},
] as PricingProduct[],
};
serviceSetup.registerProductFeatures([feature1, feature2]);
const result = await supertest(httpSetup.server.listener)
.get('/internal/core/pricing')
.expect(200);
expect(result.body.product_features).toHaveProperty('feature_1');
expect(result.body.product_features).toHaveProperty('feature_2');
expect(result.body.product_features.feature_1).toEqual(feature1);
expect(result.body.product_features.feature_2).toEqual(feature2);
});
});
});

View file

@ -173,6 +173,9 @@
"@kbn/core-feature-flags-browser-mocks",
"@kbn/core-feature-flags-server",
"@kbn/core-feature-flags-server-mocks",
"@kbn/core-pricing-browser",
"@kbn/core-pricing-server-internal",
"@kbn/core-pricing-common",
],
"exclude": [
"target/**/*",

View file

@ -558,6 +558,20 @@
"@kbn/core-preboot-server-internal/*": ["src/core/packages/preboot/server-internal/*"],
"@kbn/core-preboot-server-mocks": ["src/core/packages/preboot/server-mocks"],
"@kbn/core-preboot-server-mocks/*": ["src/core/packages/preboot/server-mocks/*"],
"@kbn/core-pricing-browser": ["src/core/packages/pricing/browser"],
"@kbn/core-pricing-browser/*": ["src/core/packages/pricing/browser/*"],
"@kbn/core-pricing-browser-internal": ["src/core/packages/pricing/browser-internal"],
"@kbn/core-pricing-browser-internal/*": ["src/core/packages/pricing/browser-internal/*"],
"@kbn/core-pricing-browser-mocks": ["src/core/packages/pricing/browser-mocks"],
"@kbn/core-pricing-browser-mocks/*": ["src/core/packages/pricing/browser-mocks/*"],
"@kbn/core-pricing-common": ["src/core/packages/pricing/common"],
"@kbn/core-pricing-common/*": ["src/core/packages/pricing/common/*"],
"@kbn/core-pricing-server": ["src/core/packages/pricing/server"],
"@kbn/core-pricing-server/*": ["src/core/packages/pricing/server/*"],
"@kbn/core-pricing-server-internal": ["src/core/packages/pricing/server-internal"],
"@kbn/core-pricing-server-internal/*": ["src/core/packages/pricing/server-internal/*"],
"@kbn/core-pricing-server-mocks": ["src/core/packages/pricing/server-mocks"],
"@kbn/core-pricing-server-mocks/*": ["src/core/packages/pricing/server-mocks/*"],
"@kbn/core-provider-plugin": ["src/platform/test/plugin_functional/plugins/core_provider_plugin"],
"@kbn/core-provider-plugin/*": ["src/platform/test/plugin_functional/plugins/core_provider_plugin/*"],
"@kbn/core-rendering-browser": ["src/core/packages/rendering/browser"],

View file

@ -15,6 +15,7 @@ import { I18nProvider } from '@kbn/i18n-react';
import type {
PluginsServiceStart,
PricingServiceStart,
SecurityServiceStart,
UserProfileServiceStart,
} from '@kbn/core/public';
@ -103,6 +104,7 @@ export const StorybookContext: React.FC<{
theme$: EMPTY,
getTheme: () => ({ darkMode: false, name: 'amsterdam' }),
},
pricing: {} as unknown as PricingServiceStart,
security: {} as unknown as SecurityServiceStart,
userProfile: {} as unknown as UserProfileServiceStart,
plugins: {} as unknown as PluginsServiceStart,

View file

@ -4846,6 +4846,34 @@
version "0.0.0"
uid ""
"@kbn/core-pricing-browser-internal@link:src/core/packages/pricing/browser-internal":
version "0.0.0"
uid ""
"@kbn/core-pricing-browser-mocks@link:src/core/packages/pricing/browser-mocks":
version "0.0.0"
uid ""
"@kbn/core-pricing-browser@link:src/core/packages/pricing/browser":
version "0.0.0"
uid ""
"@kbn/core-pricing-common@link:src/core/packages/pricing/common":
version "0.0.0"
uid ""
"@kbn/core-pricing-server-internal@link:src/core/packages/pricing/server-internal":
version "0.0.0"
uid ""
"@kbn/core-pricing-server-mocks@link:src/core/packages/pricing/server-mocks":
version "0.0.0"
uid ""
"@kbn/core-pricing-server@link:src/core/packages/pricing/server":
version "0.0.0"
uid ""
"@kbn/core-provider-plugin@link:src/platform/test/plugin_functional/plugins/core_provider_plugin":
version "0.0.0"
uid ""