openvidu-dashboard: info no longer reports a license

GET /api/v1/info loses the license key, the feature catalogue the
license-info identifier, and the backend the LicenseInfo extension token.
OpenSpec change remove-info-license.
chore/update-openvidu-actions-1.0.25
juancarmore 2026-10-27 13:43:58 +01:00
parent 322781dd73
commit 1890b6c61f
18 changed files with 201 additions and 40 deletions

View File

@ -25,9 +25,9 @@ Behaviour is specified in `openspec/specs/*-api`, `api-conventions`, `auth`, `st
authentication and stays out of the OpenAPI document. An edition that extends CE registers the full definition in the authentication and stays out of the OpenAPI document. An edition that extends CE registers the full definition in the
lock's place (mount order kept) and swaps a CE route for its extension with `registry.replace()`; a `pro` startup lock's place (mount order kept) and swaps a CE route for its extension with `registry.replace()`; a `pro` startup
that leaves a lock without a definition exits with code 1, naming it. that leaves a lock without a definition exits with code 1, naming it.
- Extension tokens that `configure` can rebind: `Features`, `LicenseInfo`, `AnalyticsCatalogue` (metrics and - Extension tokens that `configure` can rebind: `Features`, `AnalyticsCatalogue` (metrics and dimensions the
dimensions the analytics endpoints compute) and `OverviewExtension` (extra overview fields, merged before the analytics endpoints compute) and `OverviewExtension` (extra overview fields, merged before the `ETag`). A
`ETag`). A `configure` that throws stops the startup with code 1. `configure` that throws stops the startup with code 1.
- A resource slice is `src/<resource>/`: `*-schemas.ts` (Zod), `*-query.ts` (pure filter builders), `*-repository.ts` - A resource slice is `src/<resource>/`: `*-schemas.ts` (Zod), `*-query.ts` (pure filter builders), `*-repository.ts`
(MongoDB), `*-service.ts` (paging, mapping), `*-responses.ts` (OpenAPI response schemas, checked against the mappers (MongoDB), `*-service.ts` (paging, mapping), `*-responses.ts` (OpenAPI response schemas, checked against the mappers
in unit tests) and `*-routes.ts` (`register<Resource>Routes`: the CE routes and the locks of the PRO ones). in unit tests) and `*-routes.ts` (`register<Resource>Routes`: the CE routes and the locks of the PRO ones).

View File

@ -1,7 +1,7 @@
import { Container } from 'inversify'; import { Container } from 'inversify';
import { type Db, type MongoClient } from 'mongodb'; import { type Db, type MongoClient } from 'mongodb';
import { type Edition, type FeatureKey, type InfoLicense } from '@openvidu-dashboard/typings'; import { type Edition, type FeatureKey } from '@openvidu-dashboard/typings';
import { AnalyticsCache } from '../analytics/analytics-cache.js'; import { AnalyticsCache } from '../analytics/analytics-cache.js';
import { type AnalyticsCatalogue, CE_ANALYTICS_CATALOGUE } from '../analytics/analytics-catalogue.js'; import { type AnalyticsCatalogue, CE_ANALYTICS_CATALOGUE } from '../analytics/analytics-catalogue.js';
@ -63,7 +63,6 @@ export function buildContainer(inputs: ContainerInputs): Container {
container.bind(TOKENS.Shutdown).toConstantValue(shutdown); container.bind(TOKENS.Shutdown).toConstantValue(shutdown);
container.bind<() => Date>(TOKENS.Clock).toConstantValue(() => new Date()); container.bind<() => Date>(TOKENS.Clock).toConstantValue(() => new Date());
container.bind<FeatureKey[]>(TOKENS.Features).toConstantValue([]); container.bind<FeatureKey[]>(TOKENS.Features).toConstantValue([]);
container.bind<() => InfoLicense | undefined>(TOKENS.LicenseInfo).toConstantValue(() => undefined);
container.bind(TOKENS.RouteRegistry).toConstantValue(new RouteRegistry(edition)); container.bind(TOKENS.RouteRegistry).toConstantValue(new RouteRegistry(edition));
container.bind<AnalyticsCatalogue>(TOKENS.AnalyticsCatalogue).toConstantValue(CE_ANALYTICS_CATALOGUE); container.bind<AnalyticsCatalogue>(TOKENS.AnalyticsCatalogue).toConstantValue(CE_ANALYTICS_CATALOGUE);
container container
@ -86,7 +85,6 @@ export function buildContainer(inputs: ContainerInputs): Container {
version: version, version: version,
features: () => container.get<FeatureKey[]>(TOKENS.Features), features: () => container.get<FeatureKey[]>(TOKENS.Features),
limits: rankedSortLimits(config.lists.totalMaxRangeMs), limits: rankedSortLimits(config.lists.totalMaxRangeMs),
license: () => container.get<() => InfoLicense | undefined>(TOKENS.LicenseInfo)(),
openapiDocsEnabled: inputs.docsEnabled, openapiDocsEnabled: inputs.docsEnabled,
statsMode statsMode
}), }),

View File

@ -10,8 +10,6 @@ export const TOKENS = {
RouteRegistry: Symbol.for('openvidu-dashboard.RouteRegistry'), RouteRegistry: Symbol.for('openvidu-dashboard.RouteRegistry'),
/** `FeatureKey[]` reported by info; empty on CE. */ /** `FeatureKey[]` reported by info; empty on CE. */
Features: Symbol.for('openvidu-dashboard.Features'), Features: Symbol.for('openvidu-dashboard.Features'),
/** `() => InfoLicense | undefined` reported by info; nothing on CE. */
LicenseInfo: Symbol.for('openvidu-dashboard.LicenseInfo'),
InfoService: Symbol.for('openvidu-dashboard.InfoService'), InfoService: Symbol.for('openvidu-dashboard.InfoService'),
SessionRepository: Symbol.for('openvidu-dashboard.SessionRepository'), SessionRepository: Symbol.for('openvidu-dashboard.SessionRepository'),
AuthService: Symbol.for('openvidu-dashboard.AuthService'), AuthService: Symbol.for('openvidu-dashboard.AuthService'),

View File

@ -62,7 +62,6 @@ const infoResponse = z
.int() .int()
.meta({ description: 'Participants and agents without a room filter' }) .meta({ description: 'Participants and agents without a room filter' })
}), }),
license: z.object({ validUntil: z.iso.date().optional(), maskedKey: z.string() }).optional(),
openapiDocsEnabled: z.boolean() openapiDocsEnabled: z.boolean()
}) })
.meta({ .meta({

View File

@ -5,7 +5,6 @@ import {
DEFAULT_RETENTION_MS, DEFAULT_RETENTION_MS,
type Edition, type Edition,
type FeatureKey, type FeatureKey,
type InfoLicense,
type InfoLimits, type InfoLimits,
type InfoMigration, type InfoMigration,
type InfoPublicResponse, type InfoPublicResponse,
@ -38,8 +37,6 @@ export interface InfoServiceOptions {
version: string; version: string;
features: () => FeatureKey[]; features: () => FeatureKey[];
limits: InfoLimits; limits: InfoLimits;
/** The license of a PRO deployment. */
license?: () => InfoLicense | undefined;
openapiDocsEnabled: boolean; openapiDocsEnabled: boolean;
/** Refreshed with the `config` document of every full read. */ /** Refreshed with the `config` document of every full read. */
statsMode?: StatsModeReader; statsMode?: StatsModeReader;
@ -67,7 +64,6 @@ export class InfoService {
const schema = byId.get('schema') as SchemaMetaSchema | undefined; const schema = byId.get('schema') as SchemaMetaSchema | undefined;
const capabilities = byId.get('capabilities') as SchemaMetaCapabilities | undefined; const capabilities = byId.get('capabilities') as SchemaMetaCapabilities | undefined;
const build = await this.db.command({ buildInfo: 1 }); const build = await this.db.command({ buildInfo: 1 });
const license = this.options.license?.();
return { return {
edition: this.options.edition, edition: this.options.edition,
@ -80,7 +76,6 @@ export class InfoService {
storage: await this.storageInfo(), storage: await this.storageInfo(),
features: this.options.features(), features: this.options.features(),
limits: this.options.limits, limits: this.options.limits,
...(license === undefined ? {} : { license }),
openapiDocsEnabled: this.options.openapiDocsEnabled openapiDocsEnabled: this.options.openapiDocsEnabled
}; };
} }

View File

@ -45,7 +45,6 @@ test('About shows the version, the retention and the storage, and no finished mi
await expect(page.getByRole('row', { name: 'Daily rollups Kept forever' })).toBeVisible(); await expect(page.getByRole('row', { name: 'Daily rollups Kept forever' })).toBeVisible();
await expect(page.getByRole('row', { name: /rooms 1,203/ })).toBeVisible(); await expect(page.getByRole('row', { name: /rooms 1,203/ })).toBeVisible();
await expect(page.getByRole('link', { name: /OpenAPI/ })).toHaveCount(0); await expect(page.getByRole('link', { name: /OpenAPI/ })).toHaveCount(0);
await expect(about).not.toContainText('License');
}); });
test.describe('incompatible writer', () => { test.describe('incompatible writer', () => {
@ -112,14 +111,11 @@ test.describe('info unavailable', () => {
}); });
test.describe('PRO', () => { test.describe('PRO', () => {
test.use({ test.use({ apiOptions: { edition: 'pro' } });
apiOptions: { edition: 'pro', info: { license: { validUntil: '2027-01-01', maskedKey: '****-ABCD' } } }
});
test('shows the PRO badge and no license section', async ({ page }) => { test('shows the PRO badge', async ({ page }) => {
await page.goto('/settings?tab=about'); await page.goto('/settings?tab=about');
await expect(page.locator('ovd-about-tab ovd-edition-badge')).toHaveText('PRO'); await expect(page.locator('ovd-about-tab ovd-edition-badge')).toHaveText('PRO');
await expect(page.getByText('****-ABCD')).toHaveCount(0);
}); });
}); });

View File

@ -42,9 +42,5 @@ export const FEATURE_COPY: Readonly<Record<FeatureKey, FeatureCopy>> = {
'per-node-breakdown': { 'per-node-breakdown': {
title: 'Per-node breakdown', title: 'Per-node breakdown',
description: 'Rooms, participants and egress per OpenVidu node.' description: 'Rooms, participants and egress per OpenVidu node.'
},
'license-info': {
title: 'License',
description: 'Validity and key of the OpenVidu PRO license.'
} }
}; };

View File

@ -73,7 +73,7 @@ describe('InfoService', () => {
expect(features.enabled('room-detail')).toBe(true); expect(features.enabled('room-detail')).toBe(true);
expect(features.enabledSignal('participant-detail')()).toBe(true); expect(features.enabledSignal('participant-detail')()).toBe(true);
expect(features.enabled('license-info')).toBe(false); expect(features.enabled('per-node-breakdown')).toBe(false);
}); });
it('re-reads the info document when a request answers 403 PRO_FEATURE', async () => { it('re-reads the info document when a request answers 403 PRO_FEATURE', async () => {

View File

@ -154,7 +154,6 @@ describe('SettingsPage', () => {
expect(tables[0]?.querySelector('tbody tr')?.textContent).toContain('Events'); expect(tables[0]?.querySelector('tbody tr')?.textContent).toContain('Events');
expect(tables[1]?.querySelector('tbody tr')?.textContent).toContain('1,203'); expect(tables[1]?.querySelector('tbody tr')?.textContent).toContain('1,203');
expect(tables[1]?.querySelector('tbody tr')?.textContent).toContain('12.3 MB'); expect(tables[1]?.querySelector('tbody tr')?.textContent).toContain('12.3 MB');
expect(about?.textContent).not.toContain('License');
expect(about?.textContent).not.toContain('Links'); expect(about?.textContent).not.toContain('Links');
expect(about?.querySelector('ovd-external-link')).toBeNull(); expect(about?.querySelector('ovd-external-link')).toBeNull();
}); });

View File

@ -322,7 +322,7 @@ describe('DashboardShell', () => {
it('keeps the short edition badge and a documentation icon in the rail', async () => { it('keeps the short edition badge and a documentation icon in the rail', async () => {
size.set('medium'); size.set('medium');
const harness = await shell('/settings', ['license-info']); const harness = await shell('/settings', ['per-node-breakdown']);
const footer = (harness.fixture.nativeElement as HTMLElement).querySelector( const footer = (harness.fixture.nativeElement as HTMLElement).querySelector(
'ovd-navigation-list ovd-sidenav-footer' 'ovd-navigation-list ovd-sidenav-footer'
); );

View File

@ -8,7 +8,6 @@ export const FeatureKey = {
ANALYTICS_DIAGNOSTICS: 'analytics-diagnostics', ANALYTICS_DIAGNOSTICS: 'analytics-diagnostics',
ANALYTICS_TOP_ROOMS: 'analytics-top-rooms', ANALYTICS_TOP_ROOMS: 'analytics-top-rooms',
TRACK_DETAIL: 'track-detail', TRACK_DETAIL: 'track-detail',
PER_NODE_BREAKDOWN: 'per-node-breakdown', PER_NODE_BREAKDOWN: 'per-node-breakdown'
LICENSE_INFO: 'license-info'
} as const; } as const;
export type FeatureKey = (typeof FeatureKey)[keyof typeof FeatureKey]; export type FeatureKey = (typeof FeatureKey)[keyof typeof FeatureKey];

View File

@ -63,14 +63,6 @@ export interface InfoStorageEntry {
oldest?: string; oldest?: string;
} }
/** License of a PRO deployment; CE never reports one. */
export interface InfoLicense {
/** Last valid day, when the licensing service reports one. */
validUntil?: string;
/** The license key with every character but the last four masked. */
maskedKey: string;
}
/** Widest resolved ranges on which the lists accept a ranked (metric) sort. */ /** Widest resolved ranges on which the lists accept a ranked (metric) sort. */
export interface InfoLimits { export interface InfoLimits {
/** Rooms, egresses and ingresses: `DASHBOARD_LIST_TOTAL_MAX_RANGE`. */ /** Rooms, egresses and ingresses: `DASHBOARD_LIST_TOTAL_MAX_RANGE`. */
@ -96,6 +88,5 @@ export interface InfoResponse {
storage: Record<string, InfoStorageEntry>; storage: Record<string, InfoStorageEntry>;
features: FeatureKey[]; features: FeatureKey[];
limits: InfoLimits; limits: InfoLimits;
license?: InfoLicense;
openapiDocsEnabled: boolean; openapiDocsEnabled: boolean;
} }

View File

@ -2,7 +2,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest';
import { import {
DEFAULT_PREFERENCES, DEFAULT_PREFERENCES,
type InfoLicense,
type InfoMigration, type InfoMigration,
type InfoResponse, type InfoResponse,
type InfoRetention, type InfoRetention,
@ -49,7 +48,6 @@ describe('UI contract', () => {
expectTypeOf<InfoResponse['retention']>().toEqualTypeOf<InfoRetention | undefined>(); expectTypeOf<InfoResponse['retention']>().toEqualTypeOf<InfoRetention | undefined>();
expectTypeOf<InfoResponse['storage']>().toEqualTypeOf<Record<string, InfoStorageEntry>>(); expectTypeOf<InfoResponse['storage']>().toEqualTypeOf<Record<string, InfoStorageEntry>>();
expectTypeOf<InfoResponse['database']['version']>().toBeString(); expectTypeOf<InfoResponse['database']['version']>().toBeString();
expectTypeOf<InfoResponse['license']>().toEqualTypeOf<InfoLicense | undefined>();
expectTypeOf<InfoResponse['openapiDocsEnabled']>().toBeBoolean(); expectTypeOf<InfoResponse['openapiDocsEnabled']>().toBeBoolean();
expectTypeOf<InfoResponse['features']>().not.toBeAny(); expectTypeOf<InfoResponse['features']>().not.toBeAny();
}); });

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-27

View File

@ -0,0 +1,43 @@
# Design
## Context
- `InfoService.build()` reads an optional `license` callback, bound in the container to `TOKENS.LicenseInfo` (`() =>
undefined` on CE). It spreads the result into the response only when it is defined.
- The `GET /api/v1/info` route declares `license` as an optional object of its zod response schema, so the OpenAPI
document shows it.
- `FeatureKey.LICENSE_INFO` has a copy in `FEATURE_COPY`, but no route or page gates on it.
## Goals / Non-Goals
**Goals:**
- No edition can add license data to `GET /api/v1/info`, and the OpenAPI document does not suggest it.
**Non-Goals:**
- Changing `info.license` of the OpenAPI document or `apiLicense`.
- Any other key of the info response.
## Decisions
### Remove the seam, not only the field
The token, the service option, the type and the schema property all go. An extending edition can no longer rebind
`LicenseInfo`, because the token no longer exists. The response schema has no `license` property, so the document
never declares it.
Alternative rejected: keeping the token with a CE default and dropping only the schema property. The response
would still carry whatever a rebinding returned, without documentation.
### Drop the feature identifier
`license-info` gated nothing. Keeping it would advertise a feature in `features` that no page offers.
### OpenAPI shape
- `GET /api/v1/info`: the `200` schema loses `license`, and `features` items lose `license-info`.
- Paths, parameters and status codes do not change. No query, index or performance budget is involved.
## Risks / Trade-offs
- **[Removing a key from `v1`]** → The key was never released, so the versioning policy for released fields does not
apply.

View File

@ -0,0 +1,44 @@
# Proposal
## Why
`GET /api/v1/info` declares an optional `license` block, and the feature catalogue a `license-info` key. No page
shows either, and the dashboard is not meant to expose license data, in the UI or through the API.
## What Changes
- **BREAKING (unreleased):** `GET /api/v1/info` loses the optional `license` key: no edition reports it, and the
OpenAPI document no longer declares it.
- The `license-info` identifier leaves the `FeatureKey` enumeration and the lock catalogue of the UI.
- The backend loses the `LicenseInfo` extension token, the `InfoLicense` type and the info service option that
filled the key.
Scope excludes the following:
- The OpenAPI document's `info.license` (the license of the API itself) and the `apiLicense` option of
`startDashboard()`.
- CE's lack of license checks, which stays as specified.
Edition: CE, and the seams an extending edition uses. Repository: dashboard only (`openvidu-dashboard/` in
`OpenVidu/openvidu`).
CHANGELOG: none. The key and the feature identifier never shipped in a release.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `editions-info`: the Info endpoint keys, the migration outcome requirement (replaced by one without the license)
and the feature catalogue.
## Impact
- Typings: `api/info.ts` (`InfoLicense`, `InfoResponse.license`) and `api/features.ts` (`LICENSE_INFO`).
- Backend: `di/tokens.ts`, `di/container.ts`, `info/info-service.ts` and `info/info-routes.ts`. Rebinding
`LicenseInfo` from `DashboardOptions.configure` is no longer possible.
- Frontend: `components/lock/features.ts`.
- Tests: the typings UI contract, the info, shell and settings specs, and the settings e2e suite.
- Docs: `dashboard-ce/backend/AGENTS.md`, which lists the extension tokens.

View File

@ -0,0 +1,71 @@
## MODIFIED Requirements
### Requirement: Info endpoint
Edition: CE. With a valid bearer token `GET /api/v1/info` SHALL answer `200` with the keys `edition`, `versions`,
`schema`, `migration`, `retention?`, `database`, `storage`, `features`, `limits` and `openapiDocsEnabled`, where
`schema.status` is the writer schema gate's `ok`, `newer`, `older` or `uninitialized` and `openapiDocsEnabled` tells
whether the docs routes are served.
#### Scenario: CE with a running writer
- **GIVEN** the CE dashboard 3.10.0 expects schema 1 and `schema_meta` records writer schema 1, server 3.10.0, a
completed migration and the default retention, with the default `DASHBOARD_LIST_TOTAL_MAX_RANGE`
- **WHEN** the client requests `GET /api/v1/info` with a valid token
- **THEN** the response is `200` with `edition: "ce"`, `versions: { dashboard: "3.10.0", server: "3.10.0" }`,
`schema: { dashboard: 1, writer: 1, status: "ok" }`, `migration.state: "completed"`, `migration.progress.processed`
and `.total`, `retention: { eventsMs: 604800000, statsMs: 604800000, tracksMs: 604800000,
entitiesMs: 7776000000, rollups5MinMs: 604800000, rollupsHourlyMs: 34560000000, rollupsDailyMs: null }`,
`features: []`, `limits: { rankedSortMaxRangeMs: 604800000, participantRankedSortMaxRangeMs: 86400000 }` and
`openapiDocsEnabled: false`
#### Scenario: Migration in progress
- **GIVEN** the writer's migration document has `state: 'running'`, `processed: 400000` and `total: 1200000`
- **WHEN** the client requests `GET /api/v1/info` with a valid token
- **THEN** `migration` is `{ state: "running", progress: { processed: 400000, total: 1200000, startedAt } }`
#### Scenario: Nested keys
- **WHEN** a client with a valid bearer token requests `GET /api/v1/info`
- **THEN** `versions` holds `dashboard` and `server?`, `schema` holds `dashboard`, `writer?` and `status`, `migration`
holds `state`, `progress?` and `error?`, and `limits` holds `rankedSortMaxRangeMs` and
`participantRankedSortMaxRangeMs`
### Requirement: Feature catalogue
Edition: CE. `features` SHALL be an array of `FeatureKey` identifiers from a shared enumeration (`room-detail`,
`participant-detail`, `egress-detail`, `ingress-detail`, `analytics-quality`, `analytics-diagnostics`,
`analytics-top-rooms`, `track-detail`, `per-node-breakdown`), empty on CE; every PRO feature has its identifier in it.
#### Scenario: CE feature list
- **WHEN** the CE dashboard answers `GET /api/v1/info`
- **THEN** `features` is `[]`
#### Scenario: Unknown identifier tolerated
- **GIVEN** a PRO backend newer than the UI reports a feature the UI does not know
- **WHEN** the UI reads `features`
- **THEN** it ignores the unknown identifier and keeps working
## ADDED Requirements
### Requirement: Migration outcome in info
Edition: CE. `migration.progress` SHALL carry `processed`, `total`, `startedAt?` and `finishedAt?`, and `migration.error`
SHALL carry the reason of a `failed` migration.
#### Scenario: Failed migration
- **GIVEN** the writer's migration document has `state: 'failed'`, `finishedAt` and `error: 'legacy event without a room'`
- **WHEN** the client requests `GET /api/v1/info` with a valid token
- **THEN** `migration` has `state: "failed"`, `progress.finishedAt` and `error: "legacy event without a room"`
## REMOVED Requirements
### Requirement: Migration outcome and license in info
**Reason**: Info no longer reports a license; the migration outcome moves to "Migration outcome in info".
**Migration**: None. The `license` key never shipped in a release.

View File

@ -0,0 +1,32 @@
# Tasks
## 1. Typings
- [x] 1.1 Remove `InfoLicense` and `InfoResponse.license` from `typings/src/api/info.ts`, and `LICENSE_INFO` from
`typings/src/api/features.ts`. Drop the `license` assertion of `typings/test/ui-contract.test.ts`. Verify with the
typings typecheck and tests.
## 2. Backend
- [x] 2.1 Remove `TOKENS.LicenseInfo`, its default binding and the `license` option of `InfoService`, and the
`license` property of the info response schema. Verify with the backend typecheck and unit tests, and that the
OpenAPI document of the integration suite has no `license` under the info response.
## 3. Frontend
- [x] 3.1 Remove the `license-info` entry of `FEATURE_COPY` and the uses of that identifier and of "License" in the
info, shell and settings specs. Verify with the library unit tests.
## 4. Tests
- [x] 4.1 In `e2e/tests/settings.spec.ts`, drop the `license` of the PRO info and the "License" checks, keeping the
PRO badge check. Verify with the e2e suite.
## 5. Docs and CHANGELOG
- [x] 5.1 Remove `LicenseInfo` from the extension tokens of `dashboard-ce/backend/AGENTS.md`. No CHANGELOG line: the
key never shipped. Verify by reading the file against the change.
## 6. Integration check
- [x] 6.1 Run lint, format check, typecheck, spec validation, unit, integration and e2e tests. Verify that they pass.