openvidu-dashboard: OpenAPI reference with operation IDs, described parameters and every error response

Adds the openapi-reference change. The document gains servers, license, the API conventions and described tags.
Every operation gets an operationId; parameters are described, and comma-separated lists are rendered as form
arrays. Error responses carry ApiError, and responses are declared only where an edition can answer them. The
document also covers If-None-Match and the refresh cookie.
chore/update-openvidu-actions-1.0.25
juancarmore 2026-10-27 10:42:41 +01:00
parent a03c8bb09c
commit bba918f4a5
39 changed files with 1232 additions and 155 deletions

View File

@ -76,6 +76,8 @@ queried) and `Deployment` (running the service). One line per observable change,
- Overview API: `GET /api/v1/overview` with live counters, today's totals and recent rooms. - Overview API: `GET /api/v1/overview` with live counters, today's totals and recent rooms.
- Status API: `GET /api/v1/status` with the schema status, migration progress, writer health and the writer's batch - Status API: `GET /api/v1/status` with the schema status, migration progress, writer health and the writer's batch
interval, from which the writer, node and stale-data windows widen for slow batches. interval, from which the writer, node and stale-data windows widen for slow batches.
- API reference outside production: an OpenAPI 3.1 document at `/api/v1/docs/openapi.json` and a browsable page at
`/api/v1/docs`, with operation IDs, described parameters and every error response.
#### Deployment #### Deployment

View File

@ -12,9 +12,15 @@ Behaviour is specified in `openspec/specs/*-api`, `api-conventions`, `auth`, `st
`TOKENS` (`src/di/tokens.ts`) are bound with `toResolvedValue` factories, without decorators, so esbuild and Vitest `TOKENS` (`src/di/tokens.ts`) are bound with `toResolvedValue` factories, without decorators, so esbuild and Vitest
need no metadata. need no metadata.
- Routes are declared once in the `RouteRegistry` (`src/api/route-registry.ts`): method, path under `/api/v1`, edition, - Routes are declared once in the `RouteRegistry` (`src/api/route-registry.ts`): method, path under `/api/v1`, edition,
`feature` of a PRO route, security (`public`, `bearer`, `optional-bearer`), Zod schemas of params, query, body and `feature` of a PRO route, a unique `operationId` (camelCase, verb first: `listRooms`, `getRoomsSummary`), tags
responses, and the handler. It drives validation, authentication and the OpenAPI document (only when `NODE_ENV` is declared first with `registry.tag(name, description)`, security (`public`, `bearer`, `optional-bearer`, plus
not `production`). `refreshCookie` for the auth routes), Zod schemas of params, query, body and responses, and the handler. It drives
validation, authentication and the OpenAPI document (`src/api/openapi.ts`, only when `NODE_ENV` is not `production`).
- OpenAPI rules, enforced by `test/integration/openapi-document.test.ts` through `documentationIssues()`
(`test/support/openapi-checks.ts`): every parameter has a description (`.meta({ description })`; the generator lifts
one set before `.default()` or `.optional()`), comma-separated lists use `csvParam()` or an array `override`, errors
without a schema get `ApiError`, `400` and `401` are added from the input and the security, and a response only one
edition answers carries `edition`.
- PRO routes are only locks here: `registry.lock({ method, path, feature })` answers `403 PRO_FEATURE` after - PRO routes are only locks here: `registry.lock({ method, path, feature })` answers `403 PRO_FEATURE` after
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

View File

@ -17,7 +17,7 @@ import {
import type { AnalyticsService } from './analytics-service.js'; import type { AnalyticsService } from './analytics-service.js';
const TAGS = ['Analytics']; const TAGS = ['Analytics'];
const LOCKED_ITEM = { description: 'PRO metric, dimension or node scope on CE' }; const LOCKED_ITEM = { description: 'PRO metric, dimension or node scope on CE', edition: 'ce' } as const;
/** /**
* Analytics routes: series, breakdowns, summary and CSV export, with the PRO metrics, dimensions and scopes rejected on * Analytics routes: series, breakdowns, summary and CSV export, with the PRO metrics, dimensions and scopes rejected on
@ -28,12 +28,14 @@ export function registerAnalyticsRoutes(
analytics: () => AnalyticsService, analytics: () => AnalyticsService,
now: () => Date now: () => Date
): void { ): void {
registry.tag('Analytics', 'Usage series, breakdowns and KPIs aggregated from the rollups');
const ce = { edition: 'ce', tags: TAGS, security: 'bearer' } as const; const ce = { edition: 'ce', tags: TAGS, security: 'bearer' } as const;
registry.register({ registry.register({
...ce, ...ce,
method: 'get', method: 'get',
path: '/analytics/series', path: '/analytics/series',
operationId: 'getAnalyticsSeries',
summary: 'Usage series from the rollups', summary: 'Usage series from the rollups',
request: { query: analyticsSeriesQuery(now) }, request: { query: analyticsSeriesQuery(now) },
responses: { responses: {
@ -46,6 +48,7 @@ export function registerAnalyticsRoutes(
...ce, ...ce,
method: 'get', method: 'get',
path: '/analytics/breakdowns', path: '/analytics/breakdowns',
operationId: 'getAnalyticsBreakdowns',
summary: 'Counts per key of each dimension', summary: 'Counts per key of each dimension',
request: { query: analyticsBreakdownsQuery(now) }, request: { query: analyticsBreakdownsQuery(now) },
responses: { responses: {
@ -58,6 +61,7 @@ export function registerAnalyticsRoutes(
...ce, ...ce,
method: 'get', method: 'get',
path: '/analytics/summary', path: '/analytics/summary',
operationId: 'getAnalyticsSummary',
summary: 'KPI values of a range', summary: 'KPI values of a range',
request: { query: analyticsSummaryQuery(now) }, request: { query: analyticsSummaryQuery(now) },
responses: { 200: { description: 'The KPI values', schema: analyticsSummaryResponse }, 403: LOCKED_ITEM }, responses: { 200: { description: 'The KPI values', schema: analyticsSummaryResponse }, 403: LOCKED_ITEM },
@ -67,6 +71,7 @@ export function registerAnalyticsRoutes(
...ce, ...ce,
method: 'get', method: 'get',
path: '/analytics/export.csv', path: '/analytics/export.csv',
operationId: 'exportAnalyticsCsv',
summary: 'Series or breakdowns as CSV', summary: 'Series or breakdowns as CSV',
request: { query: analyticsExportQuery(now) }, request: { query: analyticsExportQuery(now) },
responses: { responses: {

View File

@ -21,7 +21,8 @@ const granularityParam = z
? 'the finest analytics granularity is 5m' ? 'the finest analytics granularity is 5m'
: `must be one of ${GRANULARITIES.join(', ')}` : `must be one of ${GRANULARITIES.join(', ')}`
}) })
.default('auto'); .default('auto')
.meta({ description: 'Bucket size; auto picks one from the length of the range' });
// A transformed schema keeps no examples in its input JSON Schema, so these go on the parameter. // A transformed schema keeps no examples in its input JSON Schema, so these go on the parameter.
const scopeParam = z const scopeParam = z
@ -29,18 +30,29 @@ const scopeParam = z
.regex(ANALYTICS_SCOPE_PATTERN, { error: 'must be global or node:<id>' }) .regex(ANALYTICS_SCOPE_PATTERN, { error: 'must be global or node:<id>' })
.transform((value) => value as AnalyticsScope) .transform((value) => value as AnalyticsScope)
.default('global') .default('global')
.meta({ description: '`global` or `node:<id>` (PRO)', param: { example: 'global' } }); .meta({
description: 'Data of every node (`global`) or of one node (`node:<id>`, PRO)',
param: { example: 'global' }
});
const metricsParam = csvParam(enumValues(AnalyticsMetric), { strict: true }).meta({ const metricsParam = csvParam(enumValues(AnalyticsMetric), { strict: true }).meta({
description: 'Comma-separated metrics, each at most once', description: 'Metrics to compute, each at most once',
param: { example: 'roomsCreated,bytesUp' } param: { example: ['roomsCreated', 'bytesUp'] }
}); });
const dimensionsParam = csvParam(enumValues(AnalyticsDimension), { strict: true }).meta({ const dimensionsParam = csvParam(enumValues(AnalyticsDimension), { strict: true }).meta({
description: 'Comma-separated dimensions, each at most once', description: 'Dimensions to break down, each at most once',
param: { example: 'sdk,os' } param: { example: ['sdk', 'os'] }
}); });
/** A comma-separated list that the series or breakdowns schema validates, rendered as an array of `values`. */
function exportList(values: readonly string[], description: string) {
return z
.string()
.optional()
.meta({ description, override: { type: 'array', items: { type: 'string', enum: [...values] } } });
}
export function analyticsSeriesQuery(now: () => Date) { export function analyticsSeriesQuery(now: () => Date) {
return rangedQuery(now, { metrics: metricsParam, granularity: granularityParam, scope: scopeParam }); return rangedQuery(now, { metrics: metricsParam, granularity: granularityParam, scope: scopeParam });
} }
@ -69,14 +81,22 @@ export function analyticsExportQuery(now: () => Date) {
return z return z
.object({ .object({
kind: z.enum(['series', 'breakdowns'], { error: 'must be series or breakdowns' }), kind: z
metrics: z.string().optional().meta({ description: 'Metrics of `kind=series`' }), .enum(['series', 'breakdowns'], { error: 'must be series or breakdowns' })
dimensions: z.string().optional().meta({ description: 'Dimensions of `kind=breakdowns`' }), .meta({ description: 'Export the series or the breakdowns' }),
granularity: z.string().optional().meta({ description: 'Granularity of `kind=series`' }), metrics: exportList(enumValues(AnalyticsMetric), 'Metrics of `kind=series`, as in the series endpoint'),
scope: z.string().optional(), dimensions: exportList(
from: z.string().optional(), enumValues(AnalyticsDimension),
to: z.string().optional(), 'Dimensions of `kind=breakdowns`, as in the breakdowns endpoint'
tz: z.string().optional() ),
granularity: z
.string()
.optional()
.meta({ description: 'Bucket size of `kind=series`', override: { enum: [...GRANULARITIES] } }),
scope: z.string().optional().meta({ description: 'Scope, as in the series and breakdowns endpoints' }),
from: z.string().optional().meta({ description: 'Start of the range, as in the series endpoint' }),
to: z.string().optional().meta({ description: 'End of the range, as in the series endpoint' }),
tz: z.string().optional().meta({ description: 'IANA time zone, as in the series endpoint' })
}) })
.transform((value, context): AnalyticsExportParams => { .transform((value, context): AnalyticsExportParams => {
const { kind, ...rest } = value; const { kind, ...rest } = value;

View File

@ -7,8 +7,9 @@ import {
type ZodOpenApiResponseObject type ZodOpenApiResponseObject
} from 'zod-openapi'; } from 'zod-openapi';
import { ErrorCode } from '@openvidu-dashboard/typings'; import { type Edition, ErrorCode } from '@openvidu-dashboard/typings';
import { REFRESH_COOKIE } from '../auth/cookies.js';
import type { RouteDefinition, RouteRegistry } from './route-registry.js'; import type { RouteDefinition, RouteRegistry } from './route-registry.js';
const errorSchema = z const errorSchema = z
@ -21,82 +22,211 @@ const errorSchema = z
}) })
.meta({ id: 'ApiError' }); .meta({ id: 'ApiError' });
const ifNoneMatch = z.object({
'If-None-Match': z
.string()
.optional()
.meta({ description: 'ETag of a previous response; the server answers 304 while it still matches' })
});
const DESCRIPTION = `REST API of the OpenVidu Dashboard, read from the analytics database that the OpenVidu server writes.
**Authentication.** \`POST /api/v1/auth/login\` returns an access token valid for 15 minutes, sent as
\`Authorization: Bearer <token>\`, and sets the \`${REFRESH_COOKIE}\` cookie, scoped to the auth endpoints;
\`POST /api/v1/auth/refresh\` exchanges that cookie for a new access token.
**Time ranges.** \`from\` and \`to\` take an ISO 8601 instant with an offset or a relative expression (\`now\`,
\`now-<n>m|h|d\`, \`now/d\`, \`now/M\`), resolved in the IANA time zone \`tz\` (default \`UTC\`).
**Pagination.** Lists return \`items\`, the opaque cursors \`nextCursor\` and \`prevCursor\`, and \`total\`. Pass a
cursor back unchanged with the same filters; \`limit\` sets the page size.
**Errors.** Every error answers \`{ error: { code, message, details? } }\` (\`ApiError\`); a \`400\` lists the invalid
parameters in \`details.issues\`.
**Conditional requests.** A response with an \`ETag\` answers \`304\` to a matching \`If-None-Match\`.
**Request IDs.** Every response carries \`X-Request-Id\`: the one the request sent, or a generated one.
**Versioning.** Additive changes (new endpoints, optional parameters, response fields and enum values) stay in \`v1\`;
clients ignore unknown fields and enum values.`;
const APACHE_LICENSE: OpenApiLicense = { name: 'Apache 2.0', identifier: 'Apache-2.0' };
export interface OpenApiLicense {
name: string;
identifier?: string;
url?: string;
}
export interface OpenApiOptions {
version: string;
/** Public base path of the dashboard, ending with `/`; default `/`. */
basePath?: string;
/** Default Apache 2.0. */
license?: OpenApiLicense;
}
/** The object a parameter schema parses: the input of a transformed object (resolved ranges, for instance). */ /** The object a parameter schema parses: the input of a transformed object (resolved ranges, for instance). */
function inputObject(schema: z.ZodType): z.ZodObject { function inputObject(schema: z.ZodType): z.ZodObject {
return (schema instanceof z.ZodPipe ? inputObject(schema.in as z.ZodType) : schema) as z.ZodObject; return (schema instanceof z.ZodPipe ? inputObject(schema.in as z.ZodType) : schema) as z.ZodObject;
} }
function operation(route: RouteDefinition, locked: boolean): ZodOpenApiOperationObject { function security(route: RouteDefinition): Record<string, string[]>[] {
switch (route.security) {
case 'bearer':
return [{ bearerAuth: [] }];
case 'optional-bearer':
return [{}, { bearerAuth: [] }];
default:
if (route.refreshCookie === undefined) {
return [];
}
return route.refreshCookie === 'required' ? [{ refreshCookie: [] }] : [{}, { refreshCookie: [] }];
}
}
function operation(route: RouteDefinition, edition: Edition): ZodOpenApiOperationObject {
const responses: Record<string, ZodOpenApiResponseObject> = {}; const responses: Record<string, ZodOpenApiResponseObject> = {};
for (const [status, response] of Object.entries(route.responses)) { for (const [status, response] of Object.entries(route.responses)) {
if (response.edition !== undefined && response.edition !== edition) {
continue;
}
const content = response.schema
? { [response.contentType ?? 'application/json']: { schema: response.schema } }
: Number(status) >= 400
? { 'application/json': { schema: errorSchema } }
: undefined;
responses[status] = { responses[status] = {
description: response.description, description: response.description,
...(response.schema ...(content ? { content } : {}),
? { content: { [response.contentType ?? 'application/json']: { schema: response.schema } } }
: {}),
...(response.headers ? { headers: response.headers } : {}) ...(response.headers ? { headers: response.headers } : {})
}; };
} }
const errors: [string, string][] = [['400', 'Invalid request']]; const errors: [string, string][] = [];
if (route.security !== 'public') { if (route.request?.params || route.request?.query || route.request?.body) {
errors.push(['401', 'Missing, invalid or expired access token']); errors.push(['400', 'Invalid request']);
} }
if (route.edition === 'pro') { if (route.security === 'bearer') {
errors.push(['403', 'PRO feature on CE']); errors.push(['401', 'Missing, invalid or expired access token']);
} else if (route.security === 'optional-bearer') {
errors.push(['401', 'Invalid or expired access token']);
} }
for (const [status, description] of errors) { for (const [status, description] of errors) {
responses[status] ??= { description, content: { 'application/json': { schema: errorSchema } } }; responses[status] ??= { description, content: { 'application/json': { schema: errorSchema } } };
} }
const security = const conditional = '304' in responses;
route.security === 'bearer'
? [{ bearerAuth: [] }]
: route.security === 'optional-bearer'
? [{}, { bearerAuth: [] }]
: [];
return { return {
operationId: route.operationId,
summary: route.summary, summary: route.summary,
tags: route.tags, tags: route.tags,
security, security: security(route),
...(locked ? { description: `PRO feature \`${route.feature ?? ''}\`: CE answers 403 PRO_FEATURE.` } : {}), ...(route.request?.params || route.request?.query || conditional
...(route.request?.params || route.request?.query
? { ? {
requestParams: { requestParams: {
...(route.request.params ? { path: inputObject(route.request.params) } : {}), ...(route.request?.params ? { path: inputObject(route.request.params) } : {}),
...(route.request.query ? { query: inputObject(route.request.query) } : {}) ...(route.request?.query ? { query: inputObject(route.request.query) } : {}),
...(conditional ? { header: ifNoneMatch } : {})
} }
} }
: {}), : {}),
...(route.request?.body ...(route.request?.body
? { requestBody: { content: { 'application/json': { schema: route.request.body } } } } ? { requestBody: { content: { 'application/json': { schema: route.request.body } } } }
: {}), : {}),
responses: responses responses
};
}
function licenseObject(license: OpenApiLicense): { name: string; identifier?: string; url?: string } {
return {
name: license.name,
...(license.identifier === undefined ? {} : { identifier: license.identifier }),
...(license.url === undefined ? {} : { url: license.url })
}; };
} }
/** OpenAPI 3.1 document of every registered route, built from the schemas that validate them. */ /** OpenAPI 3.1 document of every registered route, built from the schemas that validate them. */
export function openApiDocument(registry: RouteRegistry, version: string): ReturnType<typeof createDocument> { export function openApiDocument(registry: RouteRegistry, options: OpenApiOptions): ReturnType<typeof createDocument> {
const paths: ZodOpenApiPathsObject = {}; const paths: ZodOpenApiPathsObject = {};
const used = new Set<string>();
for (const route of registry.list()) { for (const route of registry.list()) {
const path = `/api/v1${route.path}`; const path = `/api/v1${route.path}`;
paths[path] ??= {}; paths[path] ??= {};
paths[path][route.method] = operation(route, registry.isLocked(route)); paths[path][route.method] = operation(route, registry.edition);
route.tags.forEach((tag) => used.add(tag));
} }
return createDocument({ const basePath = options.basePath ?? '/';
const document = createDocument({
openapi: '3.1.0', openapi: '3.1.0',
info: { title: 'OpenVidu Dashboard API', version }, info: {
components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } }, title: 'OpenVidu Dashboard API',
version: options.version,
description: DESCRIPTION,
license: licenseObject(options.license ?? APACHE_LICENSE)
},
servers: [{ url: basePath === '/' ? '/' : basePath.replace(/\/$/, '') }],
tags: registry.tags().filter((tag) => used.has(tag.name)),
components: {
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
refreshCookie: {
type: 'apiKey',
in: 'cookie',
name: REFRESH_COOKIE,
description: 'Refresh token set by the login, readable only by the auth endpoints'
}
}
},
paths paths
}); });
liftParameterDocs(document);
return document;
}
interface RenderedParameter {
in: string;
description?: string;
style?: string;
explode?: boolean;
schema?: { description?: unknown; type?: unknown };
}
/**
* zod-openapi keeps a description set before `.default()` or `.optional()` inside the parameter's schema; lift it to the
* parameter. A query parameter rendered as an array is a comma-separated list (`csvParam`).
*/
function liftParameterDocs(document: ReturnType<typeof createDocument>): void {
for (const item of Object.values(document.paths ?? {})) {
for (const operation of Object.values(item as Record<string, { parameters?: RenderedParameter[] }>)) {
for (const parameter of operation.parameters ?? []) {
const description = parameter.schema?.description;
if (parameter.description === undefined && typeof description === 'string') {
parameter.description = description;
}
if (parameter.in === 'query' && parameter.schema?.type === 'array') {
parameter.style = 'form';
parameter.explode = false;
}
}
}
}
} }
const SCALAR_PAGE = `<!doctype html> const SCALAR_PAGE = `<!doctype html>

View File

@ -32,12 +32,14 @@ export async function readiness(db: Db): Promise<{ ready: boolean; checks: Ready
/** `GET /health` (liveness, no MongoDB) and `GET /ready` (readiness). */ /** `GET /health` (liveness, no MongoDB) and `GET /ready` (readiness). */
export function registerProbes(registry: RouteRegistry, version: string, db: Db): void { export function registerProbes(registry: RouteRegistry, version: string, db: Db): void {
registry.tag('Probes', 'Liveness and readiness of the dashboard, for orchestrators and load balancers');
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/health', path: '/health',
operationId: 'getHealth',
edition: 'ce', edition: 'ce',
summary: 'Liveness: the process answers; MongoDB is not checked', summary: 'Liveness: the process answers; MongoDB is not checked',
tags: ['probes'], tags: ['Probes'],
security: 'public', security: 'public',
responses: { 200: { description: 'Alive', schema: healthSchema } }, responses: { 200: { description: 'Alive', schema: healthSchema } },
handler: () => ({ status: 'ok', version }) handler: () => ({ status: 'ok', version })
@ -45,9 +47,10 @@ export function registerProbes(registry: RouteRegistry, version: string, db: Db)
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/ready', path: '/ready',
operationId: 'getReadiness',
edition: 'ce', edition: 'ce',
summary: 'Readiness: MongoDB answers and the writer schema is supported', summary: 'Readiness: MongoDB answers and the writer schema is supported',
tags: ['probes'], tags: ['Probes'],
security: 'public', security: 'public',
responses: { responses: {
200: { description: 'Ready', schema: readySchema }, 200: { description: 'Ready', schema: readySchema },

View File

@ -14,18 +14,30 @@ export const tzParam = z
.default('UTC') .default('UTC')
.meta({ description: 'IANA time zone for relative snapping and buckets', examples: ['Europe/Madrid'] }); .meta({ description: 'IANA time zone for relative snapping and buckets', examples: ['Europe/Madrid'] });
const timeExpression = z const TIME_EXPRESSION = 'an ISO 8601 instant with an offset, now, now-<n>m|h|d, now/d or now/M';
const fromExpression = z
.string() .string()
.meta({ description: 'ISO 8601 with offset, now, now-<n>m|h|d, now/d or now/M', examples: ['now-24h'] }); .meta({ description: `Start of the range: ${TIME_EXPRESSION}`, examples: ['now-24h'] });
const toExpression = z.string().meta({ description: `End of the range: ${TIME_EXPRESSION}`, examples: ['now'] });
export const limitParam = z.coerce export const limitParam = z.coerce
.number({ error: 'must be an integer between 1 and 100' }) .number({ error: 'must be an integer between 1 and 100' })
.int({ error: 'must be an integer between 1 and 100' }) .int({ error: 'must be an integer between 1 and 100' })
.min(1, { error: 'must be an integer between 1 and 100' }) .min(1, { error: 'must be an integer between 1 and 100' })
.max(100, { error: 'must be an integer between 1 and 100' }) .max(100, { error: 'must be an integer between 1 and 100' })
.default(25); .default(25)
.meta({ description: 'Page size, from 1 to 100' });
export const orderParam = z.enum([SortOrder.ASC, SortOrder.DESC], { error: 'must be asc or desc' }); export const orderParam = z
.enum([SortOrder.ASC, SortOrder.DESC], { error: 'must be asc or desc' })
.meta({ description: 'Sort direction' });
/** Opaque keyset cursor of a previous page. */
export const cursorParam = z
.string()
.min(1)
.optional()
.meta({ description: 'nextCursor or prevCursor of a previous page, sent with the same filters and sort' });
/** Trimmed search text; empty means no search. */ /** Trimmed search text; empty means no search. */
export const qParam = z export const qParam = z
@ -46,7 +58,10 @@ export function csvParam<const T extends readonly [string, ...string[]]>(
const allowed = new Set<string>(values); const allowed = new Set<string>(values);
const isAllowed = (item: string): item is T[number] => allowed.has(item); const isAllowed = (item: string): item is T[number] => allowed.has(item);
return z.string().transform((value, context) => { return z
.string()
.meta({ override: { type: 'array', items: { type: 'string', enum: [...values] } } })
.transform((value, context) => {
const items = value const items = value
.split(',') .split(',')
.map((item) => item.trim()) .map((item) => item.trim())
@ -89,7 +104,7 @@ export interface ResolvedRange {
tz: string; tz: string;
} }
const rangeShape = { from: timeExpression.default(DEFAULT_FROM), to: timeExpression.default(DEFAULT_TO), tz: tzParam }; const rangeShape = { from: fromExpression.default(DEFAULT_FROM), to: toExpression.default(DEFAULT_TO), tz: tzParam };
/** Resolves `from` and `to` at `instant`, adding an issue per invalid bound; `from` must be strictly before `to`. */ /** Resolves `from` and `to` at `instant`, adding an issue per invalid bound; `from` must be strictly before `to`. */
function resolveBounds( function resolveBounds(
@ -155,7 +170,7 @@ export function timeRangeQuery(now: () => Date) {
* `now()` when given. * `now()` when given.
*/ */
export function optionalRangeQuery<S extends z.core.$ZodLooseShape>(now: () => Date, shape: S) { export function optionalRangeQuery<S extends z.core.$ZodLooseShape>(now: () => Date, shape: S) {
const input = z.object({ from: timeExpression.optional(), to: timeExpression.optional(), tz: tzParam, ...shape }); const input = z.object({ from: fromExpression.optional(), to: toExpression.optional(), tz: tzParam, ...shape });
type Output = Omit<z.output<typeof input>, 'from' | 'to'> & { from?: Date; to?: Date; tz: string }; type Output = Omit<z.output<typeof input>, 'from' | 'to'> & { from?: Date; to?: Date; tz: string };

View File

@ -44,6 +44,8 @@ export interface RouteResponse {
/** Media type of `schema`; default `application/json`. */ /** Media type of `schema`; default `application/json`. */
contentType?: string; contentType?: string;
headers?: z.ZodObject; headers?: z.ZodObject;
/** The only edition that answers it; the other editions leave it out of their OpenAPI document. */
edition?: Edition;
} }
export interface RouteDefinition<P = unknown, Q = unknown, B = unknown> { export interface RouteDefinition<P = unknown, Q = unknown, B = unknown> {
@ -54,9 +56,14 @@ export interface RouteDefinition<P = unknown, Q = unknown, B = unknown> {
edition: Edition; edition: Edition;
/** Feature of a PRO route, reported in `details.feature`. */ /** Feature of a PRO route, reported in `details.feature`. */
feature?: FeatureKey; feature?: FeatureKey;
/** Unique across the registry, camelCase and verb first, e.g. `listRooms`. */
operationId: string;
summary: string; summary: string;
/** Tags declared with `RouteRegistry.tag()`. */
tags: string[]; tags: string[];
security: RouteSecurity; security: RouteSecurity;
/** The refresh cookie the route reads, for the OpenAPI document; authentication ignores it. */
refreshCookie?: 'required' | 'optional';
request?: { params?: z.ZodType<P>; query?: z.ZodType<Q>; body?: z.ZodType<B> }; request?: { params?: z.ZodType<P>; query?: z.ZodType<Q>; body?: z.ZodType<B> };
responses: Record<number, RouteResponse>; responses: Record<number, RouteResponse>;
/** Middlewares that run before authentication, such as a rate limiter. */ /** Middlewares that run before authentication, such as a rate limiter. */
@ -93,8 +100,25 @@ function expressPath(path: string): string {
export class RouteRegistry { export class RouteRegistry {
private readonly routes = new Map<string, RouteDefinition>(); private readonly routes = new Map<string, RouteDefinition>();
private readonly locks = new Set<string>(); private readonly locks = new Set<string>();
private readonly tagDescriptions = new Map<string, string>();
constructor(private readonly edition: Edition) {} constructor(readonly edition: Edition) {}
/** Declares a tag of the OpenAPI document; routes may only use declared tags. */
tag(name: string, description: string): void {
const current = this.tagDescriptions.get(name);
if (current !== undefined && current !== description) {
throw new Error(`Tag ${name} is already declared with another description`);
}
this.tagDescriptions.set(name, description);
}
/** The declared tags, in declaration order. */
tags(): { name: string; description: string }[] {
return [...this.tagDescriptions].map(([name, description]) => ({ name, description }));
}
register<P, Q, B>(route: RouteDefinition<P, Q, B>): void { register<P, Q, B>(route: RouteDefinition<P, Q, B>): void {
const key = routeKey(route.method, route.path); const key = routeKey(route.method, route.path);
@ -112,6 +136,7 @@ export class RouteRegistry {
throw new Error(`PRO route ${key} needs a feature`); throw new Error(`PRO route ${key} needs a feature`);
} }
this.checkDocumentation(key, route as RouteDefinition);
// Setting an existing key keeps its position, so a lock is replaced where it was registered. // Setting an existing key keeps its position, so a lock is replaced where it was registered.
this.locks.delete(key); this.locks.delete(key);
this.routes.set(key, route as RouteDefinition); this.routes.set(key, route as RouteDefinition);
@ -129,6 +154,7 @@ export class RouteRegistry {
this.routes.set(key, { this.routes.set(key, {
...lock, ...lock,
edition: 'pro', edition: 'pro',
operationId: '',
summary: '', summary: '',
tags: [], tags: [],
security: 'bearer', security: 'bearer',
@ -144,6 +170,7 @@ export class RouteRegistry {
throw new Error(`Route ${key} is not registered`); throw new Error(`Route ${key} is not registered`);
} }
this.checkDocumentation(key, route as RouteDefinition);
this.routes.set(key, route as RouteDefinition); this.routes.set(key, route as RouteDefinition);
} }
@ -162,6 +189,20 @@ export class RouteRegistry {
return route.edition === 'pro' && this.edition === 'ce'; return route.edition === 'pro' && this.edition === 'ce';
} }
private checkDocumentation(key: string, route: RouteDefinition): void {
const undeclared = route.tags.find((tag) => !this.tagDescriptions.has(tag));
if (undeclared !== undefined) {
throw new Error(`Route ${key} uses the undeclared tag ${undeclared}`);
}
for (const [other, existing] of this.routes) {
if (other !== key && !this.locks.has(other) && existing.operationId === route.operationId) {
throw new Error(`Route ${key} reuses the operationId ${route.operationId} of ${other}`);
}
}
}
router(authenticate: Authenticator): Router { router(authenticate: Authenticator): Router {
const router = Router(); const router = Router();

View File

@ -6,7 +6,13 @@ import { ApiError } from '../http/api-error.js';
import { errorBody } from '../http/errors.js'; import { errorBody } from '../http/errors.js';
import type { RouteRegistry } from '../api/route-registry.js'; import type { RouteRegistry } from '../api/route-registry.js';
import type { AuthService, ClientInfo } from './auth-service.js'; import type { AuthService, ClientInfo } from './auth-service.js';
import { clearRefreshCookie, readRefreshCookie, type RefreshCookiePolicy, setRefreshCookie } from './cookies.js'; import {
clearRefreshCookie,
readRefreshCookie,
REFRESH_COOKIE,
type RefreshCookiePolicy,
setRefreshCookie
} from './cookies.js';
export const LOGIN_WINDOW_MS = 15 * 60_000; export const LOGIN_WINDOW_MS = 15 * 60_000;
export const LOGIN_FAILED_ATTEMPTS = 10; export const LOGIN_FAILED_ATTEMPTS = 10;
@ -22,6 +28,16 @@ const tokenResponse = z
.object({ accessToken: z.string(), expiresAt: z.iso.datetime() }) .object({ accessToken: z.string(), expiresAt: z.iso.datetime() })
.meta({ id: 'AuthTokenResponse' }); .meta({ id: 'AuthTokenResponse' });
const setsRefreshCookie = z.object({
'Set-Cookie': z.string().meta({
description: `${REFRESH_COOKIE}=<token>; Path=<base path>api/v1/auth; HttpOnly; SameSite=Strict (Secure over HTTPS)`
})
});
const clearsRefreshCookie = z.object({
'Set-Cookie': z.string().meta({ description: `${REFRESH_COOKIE}=; Max-Age=0: clears the refresh cookie` })
});
const meResponse = z const meResponse = z
.object({ username: z.string(), edition: z.enum(['ce', 'pro']), expiresAt: z.iso.datetime() }) .object({ username: z.string(), edition: z.enum(['ce', 'pro']), expiresAt: z.iso.datetime() })
.meta({ id: 'MeResponse' }); .meta({ id: 'MeResponse' });
@ -56,19 +72,30 @@ export function registerAuthRoutes(
auth: () => AuthService, auth: () => AuthService,
cookiePolicy: () => RefreshCookiePolicy cookiePolicy: () => RefreshCookiePolicy
): void { ): void {
registry.tag('Auth', 'Login, token refresh and logout of the administrator');
registry.register({ registry.register({
method: 'post', method: 'post',
path: '/auth/login', path: '/auth/login',
operationId: 'login',
edition: 'ce', edition: 'ce',
summary: 'Log in with the administrator credentials', summary: 'Log in with the administrator credentials',
tags: ['auth'], tags: ['Auth'],
security: 'public', security: 'public',
before: [loginLimiter()], before: [loginLimiter()],
request: { body: credentials }, request: { body: credentials },
responses: { responses: {
200: { description: 'Logged in; the refresh token is set as a cookie', schema: tokenResponse }, 200: {
description: 'Logged in; the refresh token is set as a cookie',
schema: tokenResponse,
headers: setsRefreshCookie
},
401: { description: 'Wrong credentials (INVALID_CREDENTIALS)' }, 401: { description: 'Wrong credentials (INVALID_CREDENTIALS)' },
429: { description: 'Too many failed logins (RATE_LIMITED)' } 429: {
description: 'Too many failed logins (RATE_LIMITED)',
headers: z.object({
'Retry-After': z.string().meta({ description: 'Seconds until the next attempt is accepted' })
})
}
}, },
handler: async ({ body, req, res }) => { handler: async ({ body, req, res }) => {
const session = await auth().login(body.username, body.password, client(req)); const session = await auth().login(body.username, body.password, client(req));
@ -86,13 +113,22 @@ export function registerAuthRoutes(
registry.register({ registry.register({
method: 'post', method: 'post',
path: '/auth/refresh', path: '/auth/refresh',
operationId: 'refreshSession',
edition: 'ce', edition: 'ce',
summary: 'Rotate the refresh cookie and get a new access token', summary: 'Rotate the refresh cookie and get a new access token',
tags: ['auth'], tags: ['Auth'],
security: 'public', security: 'public',
refreshCookie: 'required',
responses: { responses: {
200: { description: 'New access token; the refresh cookie is rotated', schema: tokenResponse }, 200: {
401: { description: 'Missing, unknown, expired or revoked session; the cookie is cleared' } description: 'New access token; the refresh cookie is rotated',
schema: tokenResponse,
headers: setsRefreshCookie
},
401: {
description: 'Missing, unknown, expired or revoked session; the cookie is cleared',
headers: clearsRefreshCookie
}
}, },
handler: async ({ req, res }) => { handler: async ({ req, res }) => {
try { try {
@ -112,11 +148,13 @@ export function registerAuthRoutes(
registry.register({ registry.register({
method: 'post', method: 'post',
path: '/auth/logout', path: '/auth/logout',
operationId: 'logout',
edition: 'ce', edition: 'ce',
summary: 'End the session of the refresh cookie', summary: 'End the session of the refresh cookie',
tags: ['auth'], tags: ['Auth'],
security: 'public', security: 'public',
responses: { 204: { description: 'Logged out; the cookie is cleared' } }, refreshCookie: 'optional',
responses: { 204: { description: 'Logged out; the cookie is cleared', headers: clearsRefreshCookie } },
handler: async ({ req, res }) => { handler: async ({ req, res }) => {
await auth().logout(readRefreshCookie(req)); await auth().logout(readRefreshCookie(req));
clearRefreshCookie(res, req, cookiePolicy()); clearRefreshCookie(res, req, cookiePolicy());
@ -127,9 +165,10 @@ export function registerAuthRoutes(
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/auth/me', path: '/auth/me',
operationId: 'getCurrentAdmin',
edition: 'ce', edition: 'ce',
summary: 'The authenticated administrator', summary: 'The authenticated administrator',
tags: ['auth'], tags: ['Auth'],
security: 'bearer', security: 'bearer',
responses: { 200: { description: 'Current session', schema: meResponse } }, responses: { 200: { description: 'Current session', schema: meResponse } },
handler: ({ req }) => { handler: ({ req }) => {

View File

@ -12,9 +12,11 @@ const TAGS = ['Egress'];
* `now` is read per request. * `now` is read per request.
*/ */
export function registerEgressRoutes(registry: RouteRegistry, egress: () => EgressService, now: () => Date): void { export function registerEgressRoutes(registry: RouteRegistry, egress: () => EgressService, now: () => Date): void {
registry.tag('Egress', 'Recordings and streams');
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/egresses', path: '/egresses',
operationId: 'listEgresses',
edition: 'ce', edition: 'ce',
summary: 'Egresses started in a range', summary: 'Egresses started in a range',
tags: TAGS, tags: TAGS,
@ -27,6 +29,7 @@ export function registerEgressRoutes(registry: RouteRegistry, egress: () => Egre
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/egresses/summary', path: '/egresses/summary',
operationId: 'getEgressesSummary',
edition: 'ce', edition: 'ce',
summary: 'Egress counts of a range', summary: 'Egress counts of a range',
tags: TAGS, tags: TAGS,

View File

@ -2,23 +2,28 @@ import * as z from 'zod';
import { EgressSort, EgressStatus, EgressType, ENTITY_ID_PATTERNS } from '@openvidu-dashboard/typings'; import { EgressSort, EgressStatus, EgressType, ENTITY_ID_PATTERNS } from '@openvidu-dashboard/typings';
import { csvParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; import { csvParam, cursorParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js';
import { ROOM_SID } from '../rooms/rooms-schemas.js'; import { ROOM_SID } from '../rooms/rooms-schemas.js';
import { enumValues } from '../util/enum-values.js'; import { enumValues } from '../util/enum-values.js';
export const EGRESS_ID = ENTITY_ID_PATTERNS.egress; export const EGRESS_ID = ENTITY_ID_PATTERNS.egress;
const STATUSES = enumValues(EgressStatus); const STATUSES = enumValues(EgressStatus);
const cursorParam = z.string().min(1).optional();
/** Room name prefix of two characters or more; an egress id is accepted whatever its length. */ /** Room name prefix of two characters or more; an egress id is accepted whatever its length. */
const egressSearch = qParam.refine((value) => value === undefined || EGRESS_ID.test(value) || value.length >= 2, { const egressSearch = qParam
.refine((value) => value === undefined || EGRESS_ID.test(value) || value.length >= 2, {
error: 'must have at least two characters' error: 'must have at least two characters'
})
.meta({
description:
'Case-insensitive prefix of the room name (two characters or more), or an exact egress ID that ignores the ' +
'range and the other filters'
}); });
/** `all`, or a comma-separated subset of the egress statuses. */ /** `all`, or a comma-separated subset of the egress statuses. */
const statusParam = z const statusParam = z
.string() .string()
.meta({ override: { type: 'array', items: { type: 'string', enum: ['all', ...STATUSES] }, default: ['all'] } })
.transform((value, context): 'all' | EgressStatus[] => { .transform((value, context): 'all' | EgressStatus[] => {
if (value.trim() === 'all') { if (value.trim() === 'all') {
return 'all'; return 'all';
@ -44,17 +49,27 @@ const statusParam = z
return [...new Set(items)]; return [...new Set(items)];
}) })
.default('all') .default('all')
.meta({ description: '`all` or a comma-separated subset of the egress statuses' }); .meta({ description: '`all`, or the egress statuses to keep' });
export function egressesListQuery(now: () => Date) { export function egressesListQuery(now: () => Date) {
return rangedQuery(now, { return rangedQuery(now, {
status: statusParam, status: statusParam,
type: csvParam(enumValues(EgressType)).optional(), type: csvParam(enumValues(EgressType))
room: z.string().regex(ROOM_SID, { error: 'must be a room SID (RM_…)' }).optional(), .optional()
.meta({ description: 'Egress types to keep; absent means every type' }),
room: z
.string()
.regex(ROOM_SID, { error: 'must be a room SID (RM_…)' })
.optional()
.meta({ description: 'Egresses of this room (exact room SID)' }),
q: egressSearch, q: egressSearch,
sort: z sort: z
.enum(enumValues(EgressSort), { error: 'must be startedAt, endedAt or duration' }) .enum(enumValues(EgressSort), { error: 'must be startedAt, endedAt or duration' })
.default(EgressSort.STARTED_AT), .default(EgressSort.STARTED_AT)
.meta({
description:
'Sort key: startedAt in index order, the others ranked within the range limit of GET /api/v1/info'
}),
order: orderParam.default('desc'), order: orderParam.default('desc'),
cursor: cursorParam, cursor: cursorParam,
limit: limitParam limit: limitParam

View File

@ -11,7 +11,7 @@ export {
type StartupContext, type StartupContext,
type StartupHook type StartupHook
} from './lifecycle/startup.js'; } from './lifecycle/startup.js';
export { openApiDocument } from './api/openapi.js'; export { type OpenApiLicense, type OpenApiOptions, openApiDocument } from './api/openapi.js';
export { export {
type AuthContext, type AuthContext,
type HttpMethod, type HttpMethod,

View File

@ -72,12 +72,14 @@ const infoResponse = z
/** `GET /info`: public subset without `Authorization`, full document with a valid bearer token. */ /** `GET /info`: public subset without `Authorization`, full document with a valid bearer token. */
export function registerInfoRoute(registry: RouteRegistry, info: () => InfoService): void { export function registerInfoRoute(registry: RouteRegistry, info: () => InfoService): void {
registry.tag('Info', 'Edition, versions, writer schema, retention, storage, features and limits');
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/info', path: '/info',
operationId: 'getInfo',
edition: 'ce', edition: 'ce',
summary: 'Edition, versions, writer schema, migration, retention, storage and features', summary: 'Edition, versions, writer schema, migration, retention, storage and features',
tags: ['info'], tags: ['Info'],
security: 'optional-bearer', security: 'optional-bearer',
responses: { 200: { description: 'Info document', schema: infoResponse } }, responses: { 200: { description: 'Info document', schema: infoResponse } },
handler: ({ req }) => (req.auth === undefined ? info().publicInfo() : info().fullInfo()) handler: ({ req }) => (req.auth === undefined ? info().publicInfo() : info().fullInfo())

View File

@ -34,7 +34,7 @@ export const ingressSummary = z
.object({ .object({
publishingNow: count, publishingNow: count,
ingressesInRange: count, ingressesInRange: count,
byInputType: z.record(inputType, count), byInputType: z.object({ rtmp: count, whip: count, url: count }),
sessionsInRange: count, sessionsInRange: count,
totalDurationMs: z.number(), totalDurationMs: z.number(),
approximate: z.boolean() approximate: z.boolean()

View File

@ -12,9 +12,11 @@ const TAGS = ['Ingress'];
* its events). `now` is read per request. * its events). `now` is read per request.
*/ */
export function registerIngressRoutes(registry: RouteRegistry, ingress: () => IngressService, now: () => Date): void { export function registerIngressRoutes(registry: RouteRegistry, ingress: () => IngressService, now: () => Date): void {
registry.tag('Ingress', 'Ingresses and their publishing sessions');
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/ingresses', path: '/ingresses',
operationId: 'listIngresses',
edition: 'ce', edition: 'ce',
summary: 'Ingresses created in a range', summary: 'Ingresses created in a range',
tags: TAGS, tags: TAGS,
@ -27,6 +29,7 @@ export function registerIngressRoutes(registry: RouteRegistry, ingress: () => In
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/ingresses/summary', path: '/ingresses/summary',
operationId: 'getIngressesSummary',
edition: 'ce', edition: 'ce',
summary: 'Ingress counts of a range', summary: 'Ingress counts of a range',
tags: TAGS, tags: TAGS,

View File

@ -2,16 +2,20 @@ import * as z from 'zod';
import { ENTITY_ID_PATTERNS, IngressInputType, IngressSort, IngressStatus } from '@openvidu-dashboard/typings'; import { ENTITY_ID_PATTERNS, IngressInputType, IngressSort, IngressStatus } from '@openvidu-dashboard/typings';
import { limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; import { cursorParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js';
import { enumValues } from '../util/enum-values.js'; import { enumValues } from '../util/enum-values.js';
export const INGRESS_ID = ENTITY_ID_PATTERNS.ingress; export const INGRESS_ID = ENTITY_ID_PATTERNS.ingress;
const cursorParam = z.string().min(1).optional();
/** Name prefix of two characters or more; an ingress id is accepted whatever its length. */ /** Name prefix of two characters or more; an ingress id is accepted whatever its length. */
const ingressSearch = qParam.refine((value) => value === undefined || INGRESS_ID.test(value) || value.length >= 2, { const ingressSearch = qParam
.refine((value) => value === undefined || INGRESS_ID.test(value) || value.length >= 2, {
error: 'must have at least two characters' error: 'must have at least two characters'
})
.meta({
description:
'Case-insensitive prefix of the ingress name (two characters or more), or an exact ingress ID that ignores ' +
'the range and the other filters'
}); });
export function ingressesListQuery(now: () => Date) { export function ingressesListQuery(now: () => Date) {
@ -20,14 +24,17 @@ export function ingressesListQuery(now: () => Date) {
.enum(['all', ...enumValues(IngressStatus)], { .enum(['all', ...enumValues(IngressStatus)], {
error: `must be all or one of ${enumValues(IngressStatus).join(', ')}` error: `must be all or one of ${enumValues(IngressStatus).join(', ')}`
}) })
.default('all'), .default('all')
.meta({ description: 'Ingresses in this status, or all' }),
inputType: z inputType: z
.enum(enumValues(IngressInputType), { error: `must be one of ${enumValues(IngressInputType).join(', ')}` }) .enum(enumValues(IngressInputType), { error: `must be one of ${enumValues(IngressInputType).join(', ')}` })
.optional(), .optional()
.meta({ description: 'Ingresses of this input type' }),
q: ingressSearch, q: ingressSearch,
sort: z sort: z
.enum(enumValues(IngressSort), { error: 'must be createdAt, totalDuration, sessions or lastSession' }) .enum(enumValues(IngressSort), { error: 'must be createdAt, totalDuration, sessions or lastSession' })
.default(IngressSort.CREATED_AT), .default(IngressSort.CREATED_AT)
.meta({ description: 'Sort key' }),
order: orderParam.default('desc'), order: orderParam.default('desc'),
cursor: cursorParam, cursor: cursorParam,
limit: limitParam limit: limitParam

View File

@ -5,7 +5,7 @@ import { type Db, MongoClient } from 'mongodb';
import { type Edition, type InfoSchema, WriterSchemaStatus } from '@openvidu-dashboard/typings'; import { type Edition, type InfoSchema, WriterSchemaStatus } from '@openvidu-dashboard/typings';
import { docsRouter, openApiDocument } from '../api/openapi.js'; import { docsRouter, type OpenApiLicense, openApiDocument } from '../api/openapi.js';
import { type RouteRegistry } from '../api/route-registry.js'; import { type RouteRegistry } from '../api/route-registry.js';
import { type AuthService } from '../auth/auth-service.js'; import { type AuthService } from '../auth/auth-service.js';
import { bearerAuthenticator } from '../auth/bearer.js'; import { bearerAuthenticator } from '../auth/bearer.js';
@ -53,6 +53,8 @@ export interface DashboardOptions {
exit?: Exit; exit?: Exit;
/** Installs SIGTERM and SIGINT handlers (default true). */ /** Installs SIGTERM and SIGINT handlers (default true). */
handleSignals?: boolean; handleSignals?: boolean;
/** License of the API in the OpenAPI document; default Apache 2.0. */
apiLicense?: OpenApiLicense;
} }
export interface RunningDashboard { export interface RunningDashboard {
@ -217,7 +219,17 @@ export async function startDashboard(options: DashboardOptions): Promise<Running
appRoot: options.appRoot, appRoot: options.appRoot,
trustProxy: config.http.trustProxy, trustProxy: config.http.trustProxy,
apiRouter: registry.router(bearerAuthenticator(() => container.get<AuthService>(TOKENS.AuthService))), apiRouter: registry.router(bearerAuthenticator(() => container.get<AuthService>(TOKENS.AuthService))),
...(docsEnabled(env) ? { docsRouter: docsRouter(() => openApiDocument(registry, options.version)) } : {}) ...(docsEnabled(env)
? {
docsRouter: docsRouter(() =>
openApiDocument(registry, {
version: options.version,
basePath: container.get<string>(TOKENS.BasePath),
...(options.apiLicense ? { license: options.apiLicense } : {})
})
)
}
: {})
}); });
return new Promise((resolve) => { return new Promise((resolve) => {

View File

@ -14,6 +14,7 @@ export function overviewRoute(
return { return {
method: 'get', method: 'get',
path: '/overview', path: '/overview',
operationId: 'getOverview',
edition: 'ce', edition: 'ce',
summary: 'Live counters, today and recent rooms', summary: 'Live counters, today and recent rooms',
tags: ['Overview'], tags: ['Overview'],
@ -47,5 +48,6 @@ export function overviewRoute(
} }
export function registerOverviewRoutes(registry: RouteRegistry, overview: () => OverviewService): void { export function registerOverviewRoutes(registry: RouteRegistry, overview: () => OverviewService): void {
registry.tag('Overview', "Live counters, today's totals and the most recent rooms");
registry.register(overviewRoute(overview, overviewResponse)); registry.register(overviewRoute(overview, overviewResponse));
} }

View File

@ -16,9 +16,12 @@ export function registerParticipantsRoutes(
participants: () => ParticipantsService, participants: () => ParticipantsService,
now: () => Date now: () => Date
): void { ): void {
registry.tag('Participants', 'Participant sessions of every kind');
registry.tag('Agents', 'Sessions of AI agents');
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/participants', path: '/participants',
operationId: 'listParticipants',
edition: 'ce', edition: 'ce',
summary: 'Search participant sessions', summary: 'Search participant sessions',
tags: TAGS, tags: TAGS,
@ -30,6 +33,7 @@ export function registerParticipantsRoutes(
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/participants/summary', path: '/participants/summary',
operationId: 'getParticipantsSummary',
edition: 'ce', edition: 'ce',
summary: 'Participant and service counts of a range', summary: 'Participant and service counts of a range',
tags: TAGS, tags: TAGS,
@ -41,6 +45,7 @@ export function registerParticipantsRoutes(
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/agents', path: '/agents',
operationId: 'listAgents',
edition: 'ce', edition: 'ce',
summary: 'List agent sessions', summary: 'List agent sessions',
tags: ['Agents'], tags: ['Agents'],

View File

@ -2,26 +2,40 @@ import * as z from 'zod';
import { ENTITY_ID_PATTERNS, ParticipantKind, ParticipantSort, ParticipantStatus } from '@openvidu-dashboard/typings'; import { ENTITY_ID_PATTERNS, ParticipantKind, ParticipantSort, ParticipantStatus } from '@openvidu-dashboard/typings';
import { csvParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; import { csvParam, cursorParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js';
import { ROOM_SID } from '../rooms/rooms-schemas.js'; import { ROOM_SID } from '../rooms/rooms-schemas.js';
import { enumValues } from '../util/enum-values.js'; import { enumValues } from '../util/enum-values.js';
export const PARTICIPANT_SID = ENTITY_ID_PATTERNS.participant; export const PARTICIPANT_SID = ENTITY_ID_PATTERNS.participant;
/** Identity prefix of two characters or more; a participant SID is accepted whatever its length. */ /** Identity prefix of two characters or more; a participant SID is accepted whatever its length. */
const participantSearch = qParam.refine( const participantSearch = qParam
(value) => value === undefined || PARTICIPANT_SID.test(value) || value.length >= 2, .refine((value) => value === undefined || PARTICIPANT_SID.test(value) || value.length >= 2, {
{ error: 'must have at least two characters' } error: 'must have at least two characters'
); })
.meta({
const cursorParam = z.string().min(1).optional(); description:
'Case-insensitive prefix of the identity (two characters or more), or an exact participant SID that ' +
'ignores the range and the other filters'
});
const listShape = { const listShape = {
status: z.enum(enumValues(ParticipantStatus), { error: 'must be joining, active, left or aborted' }).optional(), status: z
room: z.string().regex(ROOM_SID, { error: 'must be a room SID (RM_…)' }).optional(), .enum(enumValues(ParticipantStatus), { error: 'must be joining, active, left or aborted' })
.optional()
.meta({ description: 'Sessions in this status; absent means every status' }),
room: z
.string()
.regex(ROOM_SID, { error: 'must be a room SID (RM_…)' })
.optional()
.meta({ description: 'Sessions of this room (exact room SID)' }),
sort: z sort: z
.enum(enumValues(ParticipantSort), { error: 'must be joinedAt, leftAt or duration' }) .enum(enumValues(ParticipantSort), { error: 'must be joinedAt, leftAt or duration' })
.default(ParticipantSort.JOINED_AT), .default(ParticipantSort.JOINED_AT)
.meta({
description:
'Sort key: joinedAt in index order, the others ranked within the range limit of GET /api/v1/info'
}),
order: orderParam.default('desc'), order: orderParam.default('desc'),
cursor: cursorParam, cursor: cursorParam,
limit: limitParam limit: limitParam
@ -31,7 +45,9 @@ export function participantsListQuery(now: () => Date) {
return rangedQuery(now, { return rangedQuery(now, {
...listShape, ...listShape,
q: participantSearch, q: participantSearch,
kind: csvParam(enumValues(ParticipantKind)).optional(), kind: csvParam(enumValues(ParticipantKind))
.optional()
.meta({ description: 'Participant kinds to keep; absent means every kind' }),
publisher: z publisher: z
.literal('true', { error: 'must be true' }) .literal('true', { error: 'must be true' })
.transform(() => true as const) .transform(() => true as const)

View File

@ -12,9 +12,11 @@ const TAGS = ['Rooms'];
* and series). `now` is read per request. * and series). `now` is read per request.
*/ */
export function registerRoomsRoutes(registry: RouteRegistry, rooms: () => RoomsService, now: () => Date): void { export function registerRoomsRoutes(registry: RouteRegistry, rooms: () => RoomsService, now: () => Date): void {
registry.tag('Rooms', 'Room sessions');
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/rooms', path: '/rooms',
operationId: 'listRooms',
edition: 'ce', edition: 'ce',
summary: 'List room sessions', summary: 'List room sessions',
tags: TAGS, tags: TAGS,
@ -27,6 +29,7 @@ export function registerRoomsRoutes(registry: RouteRegistry, rooms: () => RoomsS
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/rooms/summary', path: '/rooms/summary',
operationId: 'getRoomsSummary',
edition: 'ce', edition: 'ce',
summary: 'Room and participant counts of a range', summary: 'Room and participant counts of a range',
tags: TAGS, tags: TAGS,

View File

@ -2,29 +2,40 @@ import * as z from 'zod';
import { ENTITY_ID_PATTERNS, RoomFeature, RoomSort, RoomStatusFilter } from '@openvidu-dashboard/typings'; import { ENTITY_ID_PATTERNS, RoomFeature, RoomSort, RoomStatusFilter } from '@openvidu-dashboard/typings';
import { csvParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; import { csvParam, cursorParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js';
import { enumValues } from '../util/enum-values.js'; import { enumValues } from '../util/enum-values.js';
export const ROOM_SID = ENTITY_ID_PATTERNS.room; export const ROOM_SID = ENTITY_ID_PATTERNS.room;
/** Search text of two characters or more; a room SID is accepted whatever its length. */ /** Search text of two characters or more; a room SID is accepted whatever its length. */
const roomSearch = qParam.refine((value) => value === undefined || ROOM_SID.test(value) || value.length >= 2, { const roomSearch = qParam
.refine((value) => value === undefined || ROOM_SID.test(value) || value.length >= 2, {
error: 'must have at least two characters' error: 'must have at least two characters'
})
.meta({
description:
'Case-insensitive prefix of the room name (two characters or more), or an exact room SID that ignores the ' +
'range and the other filters'
}); });
const cursorParam = z.string().min(1).optional();
export function roomsListQuery(now: () => Date) { export function roomsListQuery(now: () => Date) {
return rangedQuery(now, { return rangedQuery(now, {
status: z status: z
.enum(enumValues(RoomStatusFilter), { error: 'must be active, closed or all' }) .enum(enumValues(RoomStatusFilter), { error: 'must be active, closed or all' })
.default(RoomStatusFilter.ALL), .default(RoomStatusFilter.ALL)
.meta({ description: 'Rooms active now, closed, or both' }),
q: roomSearch, q: roomSearch,
features: csvParam(enumValues(RoomFeature)).optional(), features: csvParam(enumValues(RoomFeature))
nodeId: z.string().min(1).max(128).optional(), .optional()
.meta({ description: 'Rooms that used any of these features' }),
nodeId: z.string().min(1).max(128).optional().meta({ description: 'Rooms hosted by this node' }),
sort: z sort: z
.enum(enumValues(RoomSort), { error: 'must be createdAt, endedAt, duration, uniqueIdentities or active' }) .enum(enumValues(RoomSort), { error: 'must be createdAt, endedAt, duration, uniqueIdentities or active' })
.default(RoomSort.CREATED_AT), .default(RoomSort.CREATED_AT)
.meta({
description:
'Sort key: createdAt in index order, the others ranked within the range limit of GET /api/v1/info'
}),
order: orderParam.default('desc'), order: orderParam.default('desc'),
cursor: cursorParam, cursor: cursorParam,
limit: limitParam limit: limitParam

View File

@ -27,9 +27,11 @@ export const statusResponse = z
/** `GET /status`: what every page polls to follow the server, whatever its auto-refresh. */ /** `GET /status`: what every page polls to follow the server, whatever its auto-refresh. */
export function registerStatusRoutes(registry: RouteRegistry, status: () => StatusService): void { export function registerStatusRoutes(registry: RouteRegistry, status: () => StatusService): void {
registry.tag('Status', 'Writer schema status, legacy migration progress and writer health');
registry.register({ registry.register({
method: 'get', method: 'get',
path: '/status', path: '/status',
operationId: 'getStatus',
edition: 'ce', edition: 'ce',
summary: 'Writer schema status, migration progress and writer health', summary: 'Writer schema status, migration progress and writer health',
tags: ['Status'], tags: ['Status'],

View File

@ -279,7 +279,7 @@ describe.skipIf(mongoUri === '')('analytics API', () => {
it('documents the CE analytics routes, and none of the locked ones', () => { it('documents the CE analytics routes, and none of the locked ones', () => {
const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry); const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry);
const paths = Object.keys(openApiDocument(registry, 'test').paths ?? {}); const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {});
expect(paths).toEqual( expect(paths).toEqual(
expect.arrayContaining([ expect.arrayContaining([

View File

@ -277,7 +277,7 @@ describe.skipIf(mongoUri === '')('egress API', () => {
it('documents the list and the summary, and none of the locked routes', () => { it('documents the list and the summary, and none of the locked routes', () => {
const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry); const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry);
const paths = Object.keys(openApiDocument(registry, 'test').paths ?? {}); const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {});
expect(paths).toEqual(expect.arrayContaining(['/api/v1/egresses', '/api/v1/egresses/summary'])); expect(paths).toEqual(expect.arrayContaining(['/api/v1/egresses', '/api/v1/egresses/summary']));
expect(paths.filter((path) => path.startsWith('/api/v1/egresses/{id}'))).toEqual([]); expect(paths.filter((path) => path.startsWith('/api/v1/egresses/{id}'))).toEqual([]);

View File

@ -266,7 +266,7 @@ describe.skipIf(mongoUri === '')('ingress API', () => {
it('documents the list and the summary, and none of the locked routes', () => { it('documents the list and the summary, and none of the locked routes', () => {
const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry); const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry);
const paths = Object.keys(openApiDocument(registry, 'test').paths ?? {}); const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {});
expect(paths).toEqual(expect.arrayContaining(['/api/v1/ingresses', '/api/v1/ingresses/summary'])); expect(paths).toEqual(expect.arrayContaining(['/api/v1/ingresses', '/api/v1/ingresses/summary']));
expect(paths.filter((path) => path.startsWith('/api/v1/ingresses/{id}'))).toEqual([]); expect(paths.filter((path) => path.startsWith('/api/v1/ingresses/{id}'))).toEqual([]);

View File

@ -0,0 +1,75 @@
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
import { ParticipantKind } from '@openvidu-dashboard/typings';
import { bootClient, type Client, json } from '../support/api-client.js';
import { type CheckedDocument, documentationIssues } from '../support/openapi-checks.js';
const mongoUri = inject('mongoUri');
/** The operations that may answer `403`: PRO metrics, dimensions and node scopes on CE. */
const CE_403 = ['getAnalyticsSeries', 'getAnalyticsBreakdowns', 'getAnalyticsSummary', 'exportAnalyticsCsv'];
interface Operation {
security: Record<string, string[]>[];
parameters?: { name: string; in: string; style?: string; explode?: boolean; schema: unknown }[];
responses: Record<string, { headers?: Record<string, unknown> }>;
}
describe.skipIf(mongoUri === '')('OpenAPI document', () => {
let ce: Client;
let document: CheckedDocument & {
servers: { url: string }[];
paths: Record<string, Record<string, Operation>>;
components: { securitySchemes: Record<string, unknown> };
};
beforeAll(async () => {
ce = await bootClient(mongoUri, { NODE_ENV: 'development' });
document = await json(ce, '/docs/openapi.json');
});
afterAll(async () => {
await ce.dashboard.stop();
});
it('documents every operation by the api-conventions rules', () => {
expect(documentationIssues(document, CE_403)).toEqual([]);
expect(document.servers).toEqual([{ url: '/' }]);
});
it('documents the refresh cookie of the auth routes', () => {
const refresh = document.paths['/api/v1/auth/refresh']?.post;
const logout = document.paths['/api/v1/auth/logout']?.post;
expect(document.components.securitySchemes.refreshCookie).toMatchObject({
type: 'apiKey',
in: 'cookie',
name: 'ov_dashboard_refresh'
});
expect(refresh?.security).toEqual([{ refreshCookie: [] }]);
expect(logout?.security).toEqual([{}, { refreshCookie: [] }]);
expect(Object.keys(refresh?.responses['200']?.headers ?? {})).toEqual(['Set-Cookie']);
expect(Object.keys(refresh?.responses['401']?.headers ?? {})).toEqual(['Set-Cookie']);
});
it('documents list parameters as arrays and conditional requests', () => {
const kind = document.paths['/api/v1/participants']?.get?.parameters?.find(
(parameter) => parameter.name === 'kind'
);
const overview = document.paths['/api/v1/overview']?.get;
expect(kind).toMatchObject({
style: 'form',
explode: false,
schema: {
type: 'array',
items: { type: 'string', enum: Object.values(ParticipantKind) }
}
});
expect(overview?.parameters?.map((parameter) => `${parameter.in}:${parameter.name}`)).toContain(
'header:If-None-Match'
);
expect(Object.keys(document.paths['/api/v1/health']?.get?.responses ?? {})).toEqual(['200']);
});
});

View File

@ -264,10 +264,9 @@ describe.skipIf(mongoUri === '')('overview API', () => {
}); });
it('documents the overview without nodes', () => { it('documents the overview without nodes', () => {
const document = openApiDocument( const document = openApiDocument(ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry), {
ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry), version: 'test'
'test' });
);
const schema = document.components?.schemas?.OverviewResponse as { properties?: object } | undefined; const schema = document.components?.schemas?.OverviewResponse as { properties?: object } | undefined;
expect(Object.keys(schema?.properties ?? {})).not.toContain('nodes'); expect(Object.keys(schema?.properties ?? {})).not.toContain('nodes');

View File

@ -313,7 +313,7 @@ describe.skipIf(mongoUri === '')('participants API', () => {
it('documents the lists and the summary, and none of the locked routes', () => { it('documents the lists and the summary, and none of the locked routes', () => {
const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry); const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry);
const paths = Object.keys(openApiDocument(registry, 'test').paths ?? {}); const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {});
expect(paths).toEqual( expect(paths).toEqual(
expect.arrayContaining(['/api/v1/participants', '/api/v1/participants/summary', '/api/v1/agents']) expect.arrayContaining(['/api/v1/participants', '/api/v1/participants/summary', '/api/v1/agents'])

View File

@ -306,7 +306,7 @@ describe.skipIf(mongoUri === '')('rooms API', () => {
it('documents the list and the summary, and none of the locked routes', () => { it('documents the list and the summary, and none of the locked routes', () => {
const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry); const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry);
const paths = Object.keys(openApiDocument(registry, 'test').paths ?? {}); const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {});
expect(paths).toEqual(expect.arrayContaining(['/api/v1/rooms', '/api/v1/rooms/summary'])); expect(paths).toEqual(expect.arrayContaining(['/api/v1/rooms', '/api/v1/rooms/summary']));
expect(paths.filter((path) => path.startsWith('/api/v1/rooms/{sid}'))).toEqual([]); expect(paths.filter((path) => path.startsWith('/api/v1/rooms/{sid}'))).toEqual([]);

View File

@ -113,10 +113,9 @@ describe.skipIf(mongoUri === '')('status API', () => {
}); });
it('documents the route', () => { it('documents the route', () => {
const document = openApiDocument( const document = openApiDocument(ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry), {
ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry), version: 'test'
'test' });
);
expect(Object.keys(document.paths ?? {})).toContain('/api/v1/status'); expect(Object.keys(document.paths ?? {})).toContain('/api/v1/status');
expect(Object.keys(document.components?.schemas ?? {})).toContain('StatusResponse'); expect(Object.keys(document.components?.schemas ?? {})).toContain('StatusResponse');

View File

@ -8,6 +8,7 @@ export { loadFixture, seedFixtures } from './fixtures.js';
export { freePort } from './free-port.js'; export { freePort } from './free-port.js';
export { expectWithinBudgets } from './latency-budget.js'; export { expectWithinBudgets } from './latency-budget.js';
export { type MongoHandle, startMongo } from './mongo-container.js'; export { type MongoHandle, startMongo } from './mongo-container.js';
export { type CheckedDocument, type CheckedOperation, documentationIssues } from './openapi-checks.js';
export { export {
type ExplainSummary, type ExplainSummary,
expectIdLookup, expectIdLookup,

View File

@ -0,0 +1,120 @@
/** The parts of an OpenAPI document that `documentationIssues` checks. */
export interface CheckedDocument {
info: { description?: string; license?: { name?: string } };
servers?: { url: string }[];
tags?: { name: string; description?: string }[];
paths: Record<string, Record<string, CheckedOperation>>;
}
export interface CheckedOperation {
operationId?: string;
tags?: string[];
parameters?: {
name: string;
in: string;
description?: string;
style?: string;
explode?: boolean;
schema?: { type?: string };
}[];
requestBody?: unknown;
responses: Record<string, { content?: Record<string, { schema?: { $ref?: string } }> }>;
}
/**
* Every break of the `api-conventions` rules of the OpenAPI document, one line each: metadata, unique operation IDs,
* declared tags, described parameters, list parameters as `form` arrays, `ApiError` bodies, `400` only with input,
* `If-None-Match` with `304`, and `403` only on the operations in `allowed403`.
*/
export function documentationIssues(document: CheckedDocument, allowed403: readonly string[] = []): string[] {
const issues: string[] = [];
const declared = new Map((document.tags ?? []).map((tag) => [tag.name, tag.description]));
const usedTags = new Set<string>();
const operationIds = new Set<string>();
if (!document.info.description) {
issues.push('info has no description');
}
if (!document.info.license?.name) {
issues.push('info has no license');
}
if (document.servers?.length !== 1) {
issues.push('the document needs exactly one server');
}
for (const [path, item] of Object.entries(document.paths)) {
for (const [method, operation] of Object.entries(item)) {
const name = `${method.toUpperCase()} ${path}`;
const id = operation.operationId;
if (id === undefined || id === '') {
issues.push(`${name} has no operationId`);
} else if (operationIds.has(id)) {
issues.push(`${name} repeats the operationId ${id}`);
} else {
operationIds.add(id);
}
if ((operation.tags ?? []).length === 0) {
issues.push(`${name} has no tags`);
}
for (const tag of operation.tags ?? []) {
usedTags.add(tag);
if (!declared.get(tag)) {
issues.push(`${name} uses the tag ${tag}, not declared with a description`);
}
}
const parameters = operation.parameters ?? [];
for (const parameter of parameters) {
if (!parameter.description) {
issues.push(`${name} ${parameter.in} parameter ${parameter.name} has no description`);
}
if (
parameter.in === 'query' &&
parameter.schema?.type === 'array' &&
(parameter.style !== 'form' || parameter.explode !== false)
) {
issues.push(`${name} list parameter ${parameter.name} is not a form array without explode`);
}
}
const hasInput = parameters.some((parameter) => parameter.in !== 'header') || operation.requestBody;
for (const [status, response] of Object.entries(operation.responses)) {
if (Number(status) >= 400 && response.content?.['application/json']?.schema?.$ref === undefined) {
issues.push(`${name} ${status} has no ApiError body`);
}
}
if ('400' in operation.responses && !hasInput) {
issues.push(`${name} declares 400 without any input`);
}
if ('403' in operation.responses && (id === undefined || !allowed403.includes(id))) {
issues.push(`${name} declares 403`);
}
if (
'304' in operation.responses &&
!parameters.some((parameter) => parameter.in === 'header' && parameter.name === 'If-None-Match')
) {
issues.push(`${name} answers 304 without the If-None-Match parameter`);
}
}
}
for (const tag of declared.keys()) {
if (!usedTags.has(tag)) {
issues.push(`tag ${tag} has no operation`);
}
}
return issues;
}

View File

@ -7,6 +7,7 @@ import type { ApiError as ApiErrorBody } from '@openvidu-dashboard/typings';
import * as z from 'zod'; import * as z from 'zod';
import { docsRouter, openApiDocument } from '../../src/api/openapi.js'; import { docsRouter, openApiDocument } from '../../src/api/openapi.js';
import { csvParam, limitParam } from '../../src/api/query.js';
import { type Authenticator, type RouteDefinition, RouteRegistry } from '../../src/api/route-registry.js'; import { type Authenticator, type RouteDefinition, RouteRegistry } from '../../src/api/route-registry.js';
import { ApiError } from '../../src/http/api-error.js'; import { ApiError } from '../../src/http/api-error.js';
import { createHttpApp } from '../../src/http/app.js'; import { createHttpApp } from '../../src/http/app.js';
@ -32,12 +33,14 @@ const authenticate: Authenticator = (req, required) => {
function registry(edition: 'ce' | 'pro') { function registry(edition: 'ce' | 'pro') {
const routes = new RouteRegistry(edition); const routes = new RouteRegistry(edition);
routes.tag('Rooms', 'Room sessions');
routes.register({ routes.register({
method: 'get', method: 'get',
path: '/rooms', path: '/rooms',
edition: 'ce', edition: 'ce',
operationId: 'listRooms',
summary: 'List rooms', summary: 'List rooms',
tags: ['rooms'], tags: ['Rooms'],
security: 'bearer', security: 'bearer',
request: { query: z.object({ limit: z.coerce.number().int().max(100).default(25) }) }, request: { query: z.object({ limit: z.coerce.number().int().max(100).default(25) }) },
responses: { 200: { description: 'Rooms', schema: z.object({ limit: z.number() }) } }, responses: { 200: { description: 'Rooms', schema: z.object({ limit: z.number() }) } },
@ -48,8 +51,9 @@ function registry(edition: 'ce' | 'pro') {
path: '/rooms/{sid}', path: '/rooms/{sid}',
edition: 'pro', edition: 'pro',
feature: 'room-detail', feature: 'room-detail',
operationId: 'getRoom',
summary: 'Room detail', summary: 'Room detail',
tags: ['rooms'], tags: ['Rooms'],
security: 'bearer', security: 'bearer',
request: { params: z.object({ sid: z.string() }) }, request: { params: z.object({ sid: z.string() }) },
responses: { 200: { description: 'Room' } } responses: { 200: { description: 'Room' } }
@ -62,8 +66,9 @@ const SUMMARY: RouteDefinition = {
method: 'get', method: 'get',
path: '/rooms/summary', path: '/rooms/summary',
edition: 'ce', edition: 'ce',
operationId: 'getRoomsSummary',
summary: 'Rooms summary', summary: 'Rooms summary',
tags: ['rooms'], tags: ['Rooms'],
security: 'bearer', security: 'bearer',
responses: { 200: { description: 'Summary' } }, responses: { 200: { description: 'Summary' } },
handler: () => ({ summary: true }) handler: () => ({ summary: true })
@ -74,8 +79,9 @@ const ROOM_DETAIL: RouteDefinition = {
path: '/rooms/{sid}', path: '/rooms/{sid}',
edition: 'pro', edition: 'pro',
feature: 'room-detail', feature: 'room-detail',
operationId: 'getRoom',
summary: 'Room detail', summary: 'Room detail',
tags: ['rooms'], tags: ['Rooms'],
security: 'bearer', security: 'bearer',
responses: { 200: { description: 'Room' } } responses: { 200: { description: 'Room' } }
}; };
@ -84,9 +90,15 @@ const ROOM_DETAIL: RouteDefinition = {
function locked(edition: 'ce' | 'pro') { function locked(edition: 'ce' | 'pro') {
const routes = new RouteRegistry(edition); const routes = new RouteRegistry(edition);
routes.tag('Rooms', 'Room sessions');
routes.register(SUMMARY); routes.register(SUMMARY);
routes.lock({ method: 'get', path: '/rooms/{sid}', feature: 'room-detail' }); routes.lock({ method: 'get', path: '/rooms/{sid}', feature: 'room-detail' });
routes.register({ ...SUMMARY, path: '/rooms/{sid}/events', handler: () => ({ events: [] }) }); routes.register({
...SUMMARY,
path: '/rooms/{sid}/events',
operationId: 'listRoomEvents',
handler: () => ({ events: [] })
});
return routes; return routes;
} }
@ -97,7 +109,7 @@ function app(routes: RouteRegistry, docs: boolean) {
appRoot: '/nonexistent', appRoot: '/nonexistent',
trustProxy: false, trustProxy: false,
apiRouter: routes.router(authenticate), apiRouter: routes.router(authenticate),
...(docs ? { docsRouter: docsRouter(() => openApiDocument(routes, '3.10.0')) } : {}) ...(docs ? { docsRouter: docsRouter(() => openApiDocument(routes, { version: '3.10.0' })) } : {})
}); });
} }
@ -138,6 +150,7 @@ describe('route registry', () => {
method: 'get', method: 'get',
path: '/rooms', path: '/rooms',
edition: 'ce', edition: 'ce',
operationId: 'listRoomsAgain',
summary: '', summary: '',
tags: [], tags: [],
security: 'public', security: 'public',
@ -149,6 +162,7 @@ describe('route registry', () => {
method: 'get', method: 'get',
path: '/participants/{sid}', path: '/participants/{sid}',
edition: 'pro', edition: 'pro',
operationId: 'getParticipant',
summary: '', summary: '',
tags: [], tags: [],
security: 'bearer', security: 'bearer',
@ -244,7 +258,7 @@ describe('route registry', () => {
(await request(app(routes, false)).get('/api/v1/rooms/summary').set('Authorization', 'Bearer good')).body (await request(app(routes, false)).get('/api/v1/rooms/summary').set('Authorization', 'Bearer good')).body
).toEqual({ summary: 'extended' }); ).toEqual({ summary: 'extended' });
expect(() => { expect(() => {
routes.replace({ ...SUMMARY, path: '/rooms/unknown' }); routes.replace({ ...SUMMARY, path: '/rooms/unknown', operationId: 'getUnknown' });
}).toThrow('not registered'); }).toThrow('not registered');
expect(() => { expect(() => {
routes.replace(ROOM_DETAIL); routes.replace(ROOM_DETAIL);
@ -262,3 +276,151 @@ describe('route registry', () => {
} }
}); });
}); });
describe('OpenAPI document', () => {
interface Operation {
operationId: string;
tags: string[];
security: Record<string, string[]>[];
parameters?: {
name: string;
in: string;
description?: string;
style?: string;
explode?: boolean;
schema: { type?: string; items?: { enum?: string[] } };
}[];
responses: Record<string, { content?: Record<string, { schema: { $ref?: string } }>; headers?: object }>;
}
interface Document {
info: { description: string; license: { name: string; identifier?: string } };
servers: { url: string }[];
tags: { name: string; description: string }[];
paths: Record<string, Record<string, Operation>>;
components: { securitySchemes: Record<string, { in?: string; name?: string }> };
}
function documentOf(routes: RouteRegistry, options: Partial<Parameters<typeof openApiDocument>[1]> = {}): Document {
return openApiDocument(routes, { version: '3.10.0', ...options }) as unknown as Document;
}
it('refuses an undeclared tag and a repeated operationId', () => {
const routes = registry('ce');
expect(() => {
routes.register({ ...SUMMARY, tags: ['Unknown'] });
}).toThrow('undeclared tag Unknown');
expect(() => {
routes.register({ ...SUMMARY, operationId: 'listRooms' });
}).toThrow('reuses the operationId listRooms');
expect(() => {
routes.tag('Rooms', 'Another description');
}).toThrow('already declared');
});
it('carries the conventions, the license, the base path and the used tags', () => {
const routes = registry('pro');
routes.tag('Unused', 'No operation uses it');
const document = documentOf(routes, { basePath: '/dashboard/' });
expect(document.servers).toEqual([{ url: '/dashboard' }]);
expect(document.info.license).toEqual({ name: 'Apache 2.0', identifier: 'Apache-2.0' });
expect(document.info.description).toContain('If-None-Match');
expect(document.tags).toEqual([{ name: 'Rooms', description: 'Room sessions' }]);
expect(documentOf(routes).servers).toEqual([{ url: '/' }]);
expect(documentOf(routes, { license: { name: 'Commercial' } }).info.license).toEqual({ name: 'Commercial' });
});
it('declares the errors each operation can answer, with the ApiError body', () => {
const routes = registry('pro');
routes.register({
...SUMMARY,
responses: {
200: { description: 'Summary' },
403: { description: 'Locked on CE', edition: 'ce' },
429: { description: 'Too many requests' }
}
});
const document = documentOf(routes);
const list = document.paths['/api/v1/rooms']?.get;
const detail = document.paths['/api/v1/rooms/{sid}']?.get;
const summary = document.paths['/api/v1/rooms/summary']?.get;
expect(list?.operationId).toBe('listRooms');
expect(Object.keys(list?.responses ?? {})).toEqual(['200', '400', '401']);
expect(Object.keys(detail?.responses ?? {})).toEqual(['200', '400', '401']);
expect(Object.keys(summary?.responses ?? {})).toEqual(['200', '401', '429']);
expect(summary?.responses['429']?.content?.['application/json']?.schema.$ref).toBe(
'#/components/schemas/ApiError'
);
expect(
Object.keys(documentOf(routesWithCeError('ce')).paths['/api/v1/rooms/summary']?.get?.responses ?? {})
).toContain('403');
});
it('declares If-None-Match on conditional operations and the refresh cookie of the auth routes', () => {
const routes = registry('ce');
routes.register({
...SUMMARY,
security: 'public',
refreshCookie: 'optional',
responses: { 200: { description: 'Summary' }, 304: { description: 'Unchanged' } }
});
const document = documentOf(routes);
const summary = document.paths['/api/v1/rooms/summary']?.get;
expect(summary?.parameters?.map((parameter) => `${parameter.in}:${parameter.name}`)).toEqual([
'header:If-None-Match'
]);
expect(summary?.security).toEqual([{}, { refreshCookie: [] }]);
expect(document.components.securitySchemes.refreshCookie).toMatchObject({
in: 'cookie',
name: 'ov_dashboard_refresh'
});
});
it('describes every parameter and renders comma-separated lists as arrays', () => {
const routes = new RouteRegistry('ce');
routes.tag('Rooms', 'Room sessions');
routes.register({
...SUMMARY,
request: {
query: z.object({
kind: csvParam(['STANDARD', 'AGENT']).optional().meta({ description: 'Kinds to keep' }),
limit: limitParam
})
}
});
const parameters = documentOf(routes).paths['/api/v1/rooms/summary']?.get?.parameters ?? [];
expect(parameters.find((parameter) => parameter.name === 'kind')).toMatchObject({
description: 'Kinds to keep',
style: 'form',
explode: false,
schema: { type: 'array', items: { enum: ['STANDARD', 'AGENT'] } }
});
expect(parameters.find((parameter) => parameter.name === 'limit')?.description).toBe(
'Page size, from 1 to 100'
);
});
});
/** A registry whose summary declares an error only CE answers. */
function routesWithCeError(edition: 'ce' | 'pro') {
const routes = registry(edition);
routes.register({
...SUMMARY,
responses: { 200: { description: 'Summary' }, 403: { description: 'Locked on CE', edition: 'ce' } }
});
return routes;
}

View File

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

View File

@ -0,0 +1,124 @@
# Design
## Context
- `RouteRegistry` holds one `RouteDefinition` per route, and `openApiDocument(registry, version)`
(`src/api/openapi.ts`) turns the listed routes into a zod-openapi 6 document.
- How the generator fills error responses today:
- It adds `400` to every operation, `401` to every non-public one, and `403` "PRO feature on CE" to every PRO route.
No document lists those routes in an edition that answers that 403: locks are left out in CE, and PRO never
answers it.
- It adds the `ApiError` schema only to the errors it adds itself. Errors a route declares (login `401`/`429`,
`ready` `503`, analytics `403`, detail `404`) have no body.
- Parameter descriptions come from `.meta()` on the Zod schemas:
- A `.meta()` placed before `.default()` stays inside the parameter's schema and never reaches the parameter object.
This is why `from` and `to` show no description.
- `csvParam()` parses a string, so its parameters render as plain strings.
- `DashboardOptions` carries the edition and version. The public base path is bound to `TOKENS.BasePath` by a CE
startup hook, before the docs router is built.
## Goals / Non-Goals
**Goals:**
- A document that a client generator and a reader can use as is.
- Pieces that let PRO document its routes the same way, without CE knowing them.
- Tests that keep the invariants for every route, not only for today's.
**Non-Goals:**
- Changing requests, responses, status codes or authentication.
- Serving the document in production.
## Decisions
### Explicit `operationId`, checked by the registry
- `RouteDefinition` gains a required `operationId`, camelCase, verb first:
- `list<Resources>` for paged lists;
- `get<Resource>` for single documents;
- `get<Resource><Sub>` for sub-resources and summaries;
- `login`, `refreshSession`, `logout`, `getCurrentAdmin` for auth;
- `getHealth`, `getReadiness` for the probes.
- `register()` and `replace()` throw on an `operationId` another route already uses. A lock has none and is never
listed.
Rejected: deriving IDs from method and path. Names such as `getApiV1RoomsSidSeries` read badly, and they would change
when a path changes.
### Declared tags
- `registry.tag(name, description)` declares a tag. Each route module declares its tags before registering its
routes.
- `register()` throws on an undeclared tag.
- The document lists the declared tags, in declaration order, that at least one listed operation uses.
- Tag names are Title Case nouns: `Probes`, `Auth`, `Info`, `Overview`, `Rooms`, `Participants`, `Agents`, `Egress`,
`Ingress`, `Analytics`, `Status`.
- PRO declares its own tags (such as `Tracks`) the same way.
### Error responses built by the generator
- `400` (`Invalid request`) is added only when the route has params, a query or a body.
- `401` is added for `bearer` and `optional-bearer` routes.
- The automatic `403` for PRO routes is removed.
- Every response with status ≥ 400 gets `application/json` with `ApiError` when the route gives no schema.
- `RouteResponse` gains `edition?: Edition`. A response with an edition is left out of other editions' documents. The
CE analytics routes mark their `PRO_FEATURE` `403` as `ce`. The registry exposes its edition for this.
### Parameters
- **Shared pieces in `query.ts`:**
- Shared parameters carry their descriptions after `.default()`, so zod-openapi lifts them to the parameter: `from`,
`to`, `tz`, `limit`, `order`, `cursor` and `q`.
- `to` gets its own example (`now`).
- **`csvParam(values)`:** adds `param: { style: 'form', explode: false }` and
`override: { type: 'array', items: { type: 'string', enum: values } }`. The route adds the description. The other
comma-separated parameters do the same: `features`, the egress `status` with its `all`, and the analytics `metrics`
and `dimensions`.
- **`If-None-Match`:** the generator adds this header parameter to every operation whose responses include `304`.
- **Integration test:** it asserts that every parameter of the booted document has a description. A new route without
one fails CI instead of silently degrading the document.
Alternatives rejected for the list parameters:
- Changing their schemas to `z.array()` with `explode`: Express parses `a,b` as one string, so validation would change.
- A document-wide `override` hook keyed on the transform: too implicit.
### Refresh cookie and `Set-Cookie`
- `RouteDefinition` gains an optional `refreshCookie: 'required' | 'optional'`. It only affects the document;
authentication is unchanged, and the auth routes stay `public`.
- The generator adds the `refreshCookie` security scheme (`apiKey`, `in: cookie`, name `REFRESH_COOKIE`). It sets
`security: [{ refreshCookie: [] }]` for refresh, and `[{}, { refreshCookie: [] }]` for logout, which also answers
without a cookie.
- The auth responses that set or clear the cookie declare a `Set-Cookie` header with its attributes: login `200`,
refresh `200` and `401`, logout `204`.
### Metadata
- `openApiDocument(registry, { version, basePath, license? })`:
- `servers: [{ url: basePath without its trailing slash, or '/' }]`;
- `info.description`: one Markdown text of the conventions, edition-neutral;
- `info.license`: the given one, or `{ name: 'Apache 2.0', identifier: 'Apache-2.0' }`.
- `DashboardOptions` gains an optional `apiLicense`, which an extending edition passes. CE passes nothing.
### Schema with required keys never defined
`byInputType: z.record(inputType, count)` renders `required` without `properties`. It becomes
`z.object({ rtmp: count, whip: count, url: count })`, which validates the same values.
### Unknown-route message
`apiNotFound` is mounted on `/api`, so `req.path` lacks the prefix. The message uses the path of `req.originalUrl`
(without the query string), which is the path the client sent: `${req.baseUrl}${req.path}` would turn `/api` into
`/api/`.
## Risks / Trade-offs
- **[Breaking for PRO at compile time]** `operationId` becomes required, and undeclared tags throw. → PRO adds them in
the same move of `ce.ref`, with its own document test.
- **[`override` drifting from the parser]** The array rendering duplicates the allowed values. → Both come from the same
`values` argument of `csvParam()`. The other list parameters pass their own enum constant to both the parser and the
override.
## Migration Plan
This changes the document only; deploy as usual. Clients that generated code from the old document get stable method
names from now on.

View File

@ -0,0 +1,75 @@
# Proposal
## Why
The OpenAPI document (`GET /api/v1/docs/openapi.json`) lists every route, but it does not describe the API well enough
to use it without reading the code:
- No operation has an `operationId`.
- Most parameters have no description.
- Comma-separated lists are typed as bare strings.
- Several error responses are missing or have no body schema.
- Some responses are declared that the edition never answers.
- There is no `servers`, `license` or tag list.
The unknown-route `404` also names a path without its `/api` prefix.
## What Changes
- **Document metadata:**
- `info` gains a description of the API conventions (authentication, time ranges, pagination, errors, conditional
requests, request IDs, versioning) and the license.
- `servers` gains the public base path.
- A `tags` list, with one description per tag, in a stable order.
- **Operations:**
- Every operation has a unique `operationId` and tags from that list.
- Every parameter has a description.
- Comma-separated list parameters are documented as arrays of their allowed values (`style: form`, `explode: false`).
- Routes answered conditionally declare the `If-None-Match` request header next to their `ETag` and `304`.
- The auth routes declare the refresh cookie they read (as a cookie security scheme) and the `Set-Cookie` header they
send.
- **Error responses:**
- Every error response an operation can answer is declared with the `ApiError` schema.
- `400` is declared only for operations that take input.
- A response that only one edition answers appears only in that edition's document. This covers the automatic
`PRO_FEATURE` `403`, which no listed route answers, and the `403` of PRO metrics on the CE analytics routes.
- **Schemas:** the ingress summary's `byInputType` is a plain object with its three keys. It was a record that
required keys it never defined.
- **404 message:** the unknown-route `404` message names the full request path, `No API route for GET
/api/v1/does-not-exist`.
Scope excludes the following:
- No route, parameter or response body changes.
- The PRO routes are documented by the PRO workspace on top of the generic pieces this change adds, so CE needs no
PRO-specific code, and the seams stay generic: an optional license for the document and the per-route fields.
Edition: CE, and the generic pieces PRO builds on. Repository: `OpenVidu/openvidu` (dashboard) only.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `api-conventions`:
- The OpenAPI document gains requirements on its metadata, operations, parameters and error responses.
- The unknown-route scenario fixes its message.
## Impact
- Backend:
- `src/api/openapi.ts` (generator).
- `src/api/route-registry.ts` (`operationId`, tag declarations, per-response edition, refresh cookie field).
- `src/api/query.ts` (shared parameter descriptions and the array form of `csvParam`).
- Every `src/*/*-routes.ts` (operation IDs, tags, parameter descriptions, error responses).
- `src/ingress/ingress-responses.ts`.
- `src/http/errors.ts`.
- `src/lifecycle/startup.ts` (base path, and an optional license from `DashboardOptions`).
- Tests: unit tests of the generator and the registry, and an integration test that checks the invariants above on the
document of the booted CE dashboard.
- PRO: its own routes must supply `operationId`, declared tags and parameter descriptions. That is a compile-time
requirement once it moves `ce.ref`.
- CHANGELOG (`CHANGELOG.md`, 3.10.0, Added › Data), a new line: "API reference outside production: an OpenAPI 3.1
document at `/api/v1/docs/openapi.json` and a browsable page at `/api/v1/docs`, with operation IDs, described
parameters and every error response."

View File

@ -0,0 +1,110 @@
## MODIFIED Requirements
### Requirement: Base path and resource naming
Edition: CE. Every dashboard API route SHALL live under `/api/v1`, use lower-case plural nouns for resources
(`rooms`, `participants`, `agents`, `tracks`, `egresses`, `ingresses`, `analytics`, `overview`, `status`, `auth`,
`info`), nest
sub-resources at most one level under the parent identifier, and answer every unknown `/api` path with a JSON
`404` instead of the SPA fallback.
#### Scenario: Sub-resource path
- **GIVEN** a room with SID `RM_abc`
- **WHEN** the client requests its participants
- **THEN** the path is `GET /api/v1/rooms/RM_abc/participants`
#### Scenario: Unknown API path
- **WHEN** the client requests `GET /api/v1/does-not-exist` or `GET /api/anything`
- **THEN** the response is `404` with body `{ error: { code: 'NOT_FOUND', message } }` and
`Content-Type: application/json`, never the SPA `index.html`, and the message names the full path, such as
`No API route for GET /api/v1/does-not-exist`
## ADDED Requirements
### Requirement: OpenAPI document metadata
Edition: CE. The OpenAPI document SHALL carry the following:
- an `info.description` of the API conventions;
- `info.license`: Apache 2.0 unless the edition provides another;
- one `servers` entry with the dashboard's public base path;
- a `tags` list with a description per tag, holding only the tags of the listed operations.
#### Scenario: Behind a base path
- **GIVEN** the server runs with `DASHBOARD_BASE_PATH=/dashboard/` and `NODE_ENV=development`
- **WHEN** the client reads the document
- **THEN** `servers[0].url` is `/dashboard`, and the paths start with `/api/v1`
#### Scenario: Conventions described
- **WHEN** the client reads `info.description`
- **THEN** it explains the following:
- the bearer access token and the refresh cookie;
- the `from`, `to` and `tz` time expressions;
- cursor pagination with `limit` and `total`;
- the `ApiError` envelope;
- `ETag` and `If-None-Match`;
- the `X-Request-Id` header;
- the versioning policy.
#### Scenario: Tags
- **WHEN** the client reads `tags`
- **THEN** every operation's tags are listed there with a description, and no listed tag lacks an operation
### Requirement: OpenAPI operations
Edition: CE. Every operation of the OpenAPI document SHALL have the following:
- a unique `operationId`, a summary and tags;
- a description for every parameter, with comma-separated list parameters as arrays of their allowed values
(`style: form`, `explode: false`);
- an `If-None-Match` header parameter when it can answer `304`;
- on the auth routes, the refresh cookie as a cookie security scheme, and the `Set-Cookie` header of the responses that
set or clear it.
#### Scenario: Operation identifiers
- **WHEN** the client reads every operation of the document
- **THEN** each has an `operationId` that no other operation shares, such as `listRooms` for `GET /api/v1/rooms`
#### Scenario: List parameter
- **WHEN** the client reads the `kind` parameter of `GET /api/v1/participants`
- **THEN** it has a description, `style: form`, `explode: false`, and an array schema whose items enumerate the
participant kinds
#### Scenario: Conditional request
- **WHEN** the client reads `GET /api/v1/overview`
- **THEN** it declares the `If-None-Match` header parameter, the `ETag` header of its `200`, and its `304`
#### Scenario: Refresh cookie
- **WHEN** the client reads `POST /api/v1/auth/refresh`
- **THEN** its security names the refresh cookie scheme (`in: cookie`, `name: ov_dashboard_refresh`), and its `200` and
`401` declare `Set-Cookie`
### Requirement: OpenAPI error responses
Edition: CE. Every error response an operation can answer SHALL be declared with the `ApiError` schema. This means
`400` only for operations that take parameters or a body, `401` for operations that need or accept a token, and the
route's own errors such as `404`, `429` and `503`. A response that only one edition answers SHALL appear only in that
edition's document.
#### Scenario: Operation without input
- **WHEN** the client reads `GET /api/v1/health`
- **THEN** it declares no `400`
#### Scenario: Error body
- **WHEN** the client reads the `429` of `POST /api/v1/auth/login`
- **THEN** its content is `application/json` with the `ApiError` schema
#### Scenario: Edition-specific error
- **WHEN** the client reads `GET /api/v1/analytics/series` in the CE document
- **THEN** it declares the `403` of a PRO metric, dimension or node scope; and no CE operation declares a `403`
`PRO_FEATURE` for being a PRO route

View File

@ -0,0 +1,68 @@
# Tasks
## 1. Backend: registry and generator
- [x] 1.1 In `src/api/route-registry.ts`, add:
- a required `operationId` and an optional `refreshCookie: 'required' | 'optional'` to `RouteDefinition`;
- `edition?: Edition` to `RouteResponse`;
- `tag(name, description)` and the `edition` getter to `RouteRegistry`.
`register()` and `replace()` reject a repeated `operationId` and an undeclared tag. Verify with
`route-registry.test.ts` cases for both rejections.
- [x] 1.2 In `src/api/openapi.ts`, change `openApiDocument(registry, { version, basePath, license? })` to emit:
- `servers`, `info.description`, `info.license` and `tags` (declared and used, in declaration order);
- `operationId`;
- `400` only with input, `401` for `bearer` and `optional-bearer`, no automatic `403`;
- `ApiError` on every error without a schema;
- responses filtered by `edition`;
- the `If-None-Match` parameter on operations with `304`;
- the `refreshCookie` security scheme;
and drop the dead locked-route description. Verify with `route-registry.test.ts` cases for each rule.
- [x] 1.3 In `src/lifecycle/startup.ts` and `src/index.ts`, add the optional `DashboardOptions.apiLicense` and pass it,
with the bound base path, to the document. Verify with the typecheck and the `servers` case of 1.2.
## 2. Backend: parameters and routes
- [x] 2.1 In `src/api/query.ts`, describe `from`, `to`, `tz`, `limit`, `order`, `cursor` and `q` at the parameter
level (after `.default()`), with `to` getting its own example. Make `csvParam()` render as an array of its values
with `style: form`, `explode: false`. Verify with a generator unit case on a ranged list query.
- [x] 2.2 In every `src/*/*-routes.ts` and `src/api/probes.ts`:
- declare the module's tags;
- give each route its `operationId`;
- describe every route-specific parameter;
- render the other comma-separated parameters as arrays (`features`, egress `status`, analytics `metrics` and
`dimensions`);
- mark the analytics `403` as `edition: 'ce'`;
- add `refreshCookie` and the `Set-Cookie` headers to the auth routes.
Verify with the integration test of 3.1.
- [x] 2.3 In `src/ingress/ingress-responses.ts`, make `byInputType` a `z.object` with `rtmp`, `whip` and `url`. Verify
with the ingress response unit tests and the mapper checks.
- [x] 2.4 In `src/http/errors.ts`, make `apiNotFound` name the path of `req.originalUrl`. Verify with the app unit test
of the unknown API path, which asserts the message.
## 3. Tests and docs
- [x] 3.1 Add the integration test `test/integration/openapi-document.test.ts`. It boots the CE dashboard in
development and reads `/api/v1/docs/openapi.json`, then asserts:
- unique `operationId`s;
- every tag is declared with a description;
- every parameter has a description;
- every response ≥ 400 has `ApiError`;
- no `400` on operations without input;
- no `403` `PRO_FEATURE` outside the analytics routes;
- `If-None-Match` wherever there is a `304`;
- the refresh cookie scheme on refresh and logout;
- `servers` and `license`.
Verify that `./dashboard.sh test-integration` passes.
- [x] 3.2 Lint both a development document and the generated one with Redocly CLI (`recommended`, scratch install,
not a dependency). Expect no errors, and no warnings other than intentional ones recorded in this file. Verify by
running it by hand once.
Result: valid. The only warnings are `operation-4xx-response` on `getHealth`, `getReadiness` and `logout`, which
answer no 4xx by design.
- [x] 3.3 Update `dashboard-ce/backend/AGENTS.md` (route declaration: `operationId`, tags through `registry.tag`,
parameter descriptions, response `edition`, `refreshCookie`), and add the `CHANGELOG.md` line of the proposal.
Verify that `./dashboard.sh check` passes.
## Workflow follow-up
- Move `ce.ref` in openvidu-pro and document the PRO routes there (PRO change).
- Archive when the owner asks.