diff --git a/openvidu-dashboard/dashboard-ce/backend/AGENTS.md b/openvidu-dashboard/dashboard-ce/backend/AGENTS.md index b881981d2..f920476af 100644 --- a/openvidu-dashboard/dashboard-ce/backend/AGENTS.md +++ b/openvidu-dashboard/dashboard-ce/backend/AGENTS.md @@ -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 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. -- Extension tokens that `configure` can rebind: `Features`, `LicenseInfo`, `AnalyticsCatalogue` (metrics and - dimensions the analytics endpoints compute) and `OverviewExtension` (extra overview fields, merged before the - `ETag`). A `configure` that throws stops the startup with code 1. +- Extension tokens that `configure` can rebind: `Features`, `AnalyticsCatalogue` (metrics and dimensions the + analytics endpoints compute) and `OverviewExtension` (extra overview fields, merged before the `ETag`). A + `configure` that throws stops the startup with code 1. - A resource slice is `src//`: `*-schemas.ts` (Zod), `*-query.ts` (pure filter builders), `*-repository.ts` (MongoDB), `*-service.ts` (paging, mapping), `*-responses.ts` (OpenAPI response schemas, checked against the mappers in unit tests) and `*-routes.ts` (`registerRoutes`: the CE routes and the locks of the PRO ones). diff --git a/openvidu-dashboard/dashboard-ce/backend/src/di/container.ts b/openvidu-dashboard/dashboard-ce/backend/src/di/container.ts index d94781099..bc27f9c72 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/di/container.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/di/container.ts @@ -1,7 +1,7 @@ import { Container } from 'inversify'; 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 { 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<() => Date>(TOKENS.Clock).toConstantValue(() => new Date()); container.bind(TOKENS.Features).toConstantValue([]); - container.bind<() => InfoLicense | undefined>(TOKENS.LicenseInfo).toConstantValue(() => undefined); container.bind(TOKENS.RouteRegistry).toConstantValue(new RouteRegistry(edition)); container.bind(TOKENS.AnalyticsCatalogue).toConstantValue(CE_ANALYTICS_CATALOGUE); container @@ -86,7 +85,6 @@ export function buildContainer(inputs: ContainerInputs): Container { version: version, features: () => container.get(TOKENS.Features), limits: rankedSortLimits(config.lists.totalMaxRangeMs), - license: () => container.get<() => InfoLicense | undefined>(TOKENS.LicenseInfo)(), openapiDocsEnabled: inputs.docsEnabled, statsMode }), diff --git a/openvidu-dashboard/dashboard-ce/backend/src/di/tokens.ts b/openvidu-dashboard/dashboard-ce/backend/src/di/tokens.ts index e1e6f7d99..4df2ddb25 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/di/tokens.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/di/tokens.ts @@ -10,8 +10,6 @@ export const TOKENS = { RouteRegistry: Symbol.for('openvidu-dashboard.RouteRegistry'), /** `FeatureKey[]` reported by info; empty on CE. */ 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'), SessionRepository: Symbol.for('openvidu-dashboard.SessionRepository'), AuthService: Symbol.for('openvidu-dashboard.AuthService'), diff --git a/openvidu-dashboard/dashboard-ce/backend/src/info/info-routes.ts b/openvidu-dashboard/dashboard-ce/backend/src/info/info-routes.ts index c68c6d4b2..5c6e57d10 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/info/info-routes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/info/info-routes.ts @@ -62,7 +62,6 @@ const infoResponse = z .int() .meta({ description: 'Participants and agents without a room filter' }) }), - license: z.object({ validUntil: z.iso.date().optional(), maskedKey: z.string() }).optional(), openapiDocsEnabled: z.boolean() }) .meta({ diff --git a/openvidu-dashboard/dashboard-ce/backend/src/info/info-service.ts b/openvidu-dashboard/dashboard-ce/backend/src/info/info-service.ts index a0a0279c2..df614fa80 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/info/info-service.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/info/info-service.ts @@ -5,7 +5,6 @@ import { DEFAULT_RETENTION_MS, type Edition, type FeatureKey, - type InfoLicense, type InfoLimits, type InfoMigration, type InfoPublicResponse, @@ -38,8 +37,6 @@ export interface InfoServiceOptions { version: string; features: () => FeatureKey[]; limits: InfoLimits; - /** The license of a PRO deployment. */ - license?: () => InfoLicense | undefined; openapiDocsEnabled: boolean; /** Refreshed with the `config` document of every full read. */ statsMode?: StatsModeReader; @@ -67,7 +64,6 @@ export class InfoService { const schema = byId.get('schema') as SchemaMetaSchema | undefined; const capabilities = byId.get('capabilities') as SchemaMetaCapabilities | undefined; const build = await this.db.command({ buildInfo: 1 }); - const license = this.options.license?.(); return { edition: this.options.edition, @@ -80,7 +76,6 @@ export class InfoService { storage: await this.storageInfo(), features: this.options.features(), limits: this.options.limits, - ...(license === undefined ? {} : { license }), openapiDocsEnabled: this.options.openapiDocsEnabled }; } diff --git a/openvidu-dashboard/dashboard-ce/e2e/tests/settings.spec.ts b/openvidu-dashboard/dashboard-ce/e2e/tests/settings.spec.ts index 15a3597d1..7f0a2101a 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/tests/settings.spec.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/tests/settings.spec.ts @@ -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: /rooms 1,203/ })).toBeVisible(); await expect(page.getByRole('link', { name: /OpenAPI/ })).toHaveCount(0); - await expect(about).not.toContainText('License'); }); test.describe('incompatible writer', () => { @@ -112,14 +111,11 @@ test.describe('info unavailable', () => { }); test.describe('PRO', () => { - test.use({ - apiOptions: { edition: 'pro', info: { license: { validUntil: '2027-01-01', maskedKey: '****-ABCD' } } } - }); + test.use({ apiOptions: { edition: 'pro' } }); - 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 expect(page.locator('ovd-about-tab ovd-edition-badge')).toHaveText('PRO'); - await expect(page.getByText('****-ABCD')).toHaveCount(0); }); }); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/lock/features.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/lock/features.ts index 4a9b8a77b..4fca4304c 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/lock/features.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/lock/features.ts @@ -42,9 +42,5 @@ export const FEATURE_COPY: Readonly> = { 'per-node-breakdown': { title: 'Per-node breakdown', description: 'Rooms, participants and egress per OpenVidu node.' - }, - 'license-info': { - title: 'License', - description: 'Validity and key of the OpenVidu PRO license.' } }; diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/info.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/info.spec.ts index 8aafd46b2..95d454d85 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/info.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/info.spec.ts @@ -73,7 +73,7 @@ describe('InfoService', () => { expect(features.enabled('room-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 () => { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/settings-page.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/settings-page.spec.ts index be93a8ac3..d93cef8f0 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/settings-page.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/settings-page.spec.ts @@ -154,7 +154,6 @@ describe('SettingsPage', () => { 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('12.3 MB'); - expect(about?.textContent).not.toContain('License'); expect(about?.textContent).not.toContain('Links'); expect(about?.querySelector('ovd-external-link')).toBeNull(); }); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/shell.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/shell.spec.ts index a4fa7787c..27f624942 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/shell.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/shell.spec.ts @@ -322,7 +322,7 @@ describe('DashboardShell', () => { it('keeps the short edition badge and a documentation icon in the rail', async () => { 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( 'ovd-navigation-list ovd-sidenav-footer' ); diff --git a/openvidu-dashboard/dashboard-ce/typings/src/api/features.ts b/openvidu-dashboard/dashboard-ce/typings/src/api/features.ts index a8166fa98..f28eda61d 100644 --- a/openvidu-dashboard/dashboard-ce/typings/src/api/features.ts +++ b/openvidu-dashboard/dashboard-ce/typings/src/api/features.ts @@ -8,7 +8,6 @@ export const FeatureKey = { ANALYTICS_DIAGNOSTICS: 'analytics-diagnostics', ANALYTICS_TOP_ROOMS: 'analytics-top-rooms', TRACK_DETAIL: 'track-detail', - PER_NODE_BREAKDOWN: 'per-node-breakdown', - LICENSE_INFO: 'license-info' + PER_NODE_BREAKDOWN: 'per-node-breakdown' } as const; export type FeatureKey = (typeof FeatureKey)[keyof typeof FeatureKey]; diff --git a/openvidu-dashboard/dashboard-ce/typings/src/api/info.ts b/openvidu-dashboard/dashboard-ce/typings/src/api/info.ts index 31167ffea..543a8ce9e 100644 --- a/openvidu-dashboard/dashboard-ce/typings/src/api/info.ts +++ b/openvidu-dashboard/dashboard-ce/typings/src/api/info.ts @@ -63,14 +63,6 @@ export interface InfoStorageEntry { 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. */ export interface InfoLimits { /** Rooms, egresses and ingresses: `DASHBOARD_LIST_TOTAL_MAX_RANGE`. */ @@ -96,6 +88,5 @@ export interface InfoResponse { storage: Record; features: FeatureKey[]; limits: InfoLimits; - license?: InfoLicense; openapiDocsEnabled: boolean; } diff --git a/openvidu-dashboard/dashboard-ce/typings/test/ui-contract.test.ts b/openvidu-dashboard/dashboard-ce/typings/test/ui-contract.test.ts index d4caa35af..a20afded9 100644 --- a/openvidu-dashboard/dashboard-ce/typings/test/ui-contract.test.ts +++ b/openvidu-dashboard/dashboard-ce/typings/test/ui-contract.test.ts @@ -2,7 +2,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import { DEFAULT_PREFERENCES, - type InfoLicense, type InfoMigration, type InfoResponse, type InfoRetention, @@ -49,7 +48,6 @@ describe('UI contract', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf>(); expectTypeOf().toBeString(); - expectTypeOf().toEqualTypeOf(); expectTypeOf().toBeBoolean(); expectTypeOf().not.toBeAny(); }); diff --git a/openvidu-dashboard/openspec/changes/remove-info-license/.openspec.yaml b/openvidu-dashboard/openspec/changes/remove-info-license/.openspec.yaml new file mode 100644 index 000000000..97e364202 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/remove-info-license/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-27 diff --git a/openvidu-dashboard/openspec/changes/remove-info-license/design.md b/openvidu-dashboard/openspec/changes/remove-info-license/design.md new file mode 100644 index 000000000..e0f0096c1 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/remove-info-license/design.md @@ -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. diff --git a/openvidu-dashboard/openspec/changes/remove-info-license/proposal.md b/openvidu-dashboard/openspec/changes/remove-info-license/proposal.md new file mode 100644 index 000000000..b5c3eff3a --- /dev/null +++ b/openvidu-dashboard/openspec/changes/remove-info-license/proposal.md @@ -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. diff --git a/openvidu-dashboard/openspec/changes/remove-info-license/specs/editions-info/spec.md b/openvidu-dashboard/openspec/changes/remove-info-license/specs/editions-info/spec.md new file mode 100644 index 000000000..fda12df7e --- /dev/null +++ b/openvidu-dashboard/openspec/changes/remove-info-license/specs/editions-info/spec.md @@ -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. diff --git a/openvidu-dashboard/openspec/changes/remove-info-license/tasks.md b/openvidu-dashboard/openspec/changes/remove-info-license/tasks.md new file mode 100644 index 000000000..7051afba8 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/remove-info-license/tasks.md @@ -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.