diff --git a/openvidu-dashboard/dashboard-ce/backend/AGENTS.md b/openvidu-dashboard/dashboard-ce/backend/AGENTS.md index c61f35ef8..2f33ecac7 100644 --- a/openvidu-dashboard/dashboard-ce/backend/AGENTS.md +++ b/openvidu-dashboard/dashboard-ce/backend/AGENTS.md @@ -6,9 +6,11 @@ Behaviour is specified in `openspec/specs/*-api`, `api-conventions`, `auth`, `st ## Structure - `src/lifecycle/startup.ts`: `startDashboard(options)` reads the configuration, connects to MongoDB, applies the - writer schema gate, builds the container, calls `configure` and the startup hooks, then listens. `TOKENS` - (`src/di/tokens.ts`) are bound with `toResolvedValue` factories, without decorators, so esbuild and Vitest need no - metadata. Exit codes go through `Shutdown.run(code)`. + writer schema gate, builds the container, calls `configure` and the startup hooks, then listens. Exit codes go + through `Shutdown.run(code)`. +- `src/di/container.ts`: `buildContainer()` binds the CE services and extension tokens and registers the CE routes. + `TOKENS` (`src/di/tokens.ts`) are bound with `toResolvedValue` factories, without decorators, so esbuild and Vitest + need no metadata. - 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 responses, and the handler. It drives validation, authentication and the OpenAPI document (only when `NODE_ENV` is diff --git a/openvidu-dashboard/dashboard-ce/backend/src/di/container.ts b/openvidu-dashboard/dashboard-ce/backend/src/di/container.ts new file mode 100644 index 000000000..d94781099 --- /dev/null +++ b/openvidu-dashboard/dashboard-ce/backend/src/di/container.ts @@ -0,0 +1,232 @@ +import { Container } from 'inversify'; +import { type Db, type MongoClient } from 'mongodb'; + +import { type Edition, type FeatureKey, type InfoLicense } from '@openvidu-dashboard/typings'; + +import { AnalyticsCache } from '../analytics/analytics-cache.js'; +import { type AnalyticsCatalogue, CE_ANALYTICS_CATALOGUE } from '../analytics/analytics-catalogue.js'; +import { AnalyticsFeatureGate } from '../analytics/analytics-gate.js'; +import { registerAnalyticsRoutes } from '../analytics/analytics-routes.js'; +import { AnalyticsService } from '../analytics/analytics-service.js'; +import { registerProbes } from '../api/probes.js'; +import { rankedSortLimits } from '../api/ranked-limits.js'; +import { RouteRegistry } from '../api/route-registry.js'; +import { registerAuthRoutes } from '../auth/auth-routes.js'; +import { AuthService } from '../auth/auth-service.js'; +import { SessionRepository } from '../auth/session-repository.js'; +import { type DashboardConfig } from '../config/variables.js'; +import { registerEgressRoutes } from '../egress/egress-routes.js'; +import { EgressService } from '../egress/egress-service.js'; +import { registerInfoRoute } from '../info/info-routes.js'; +import { InfoService } from '../info/info-service.js'; +import { registerIngressRoutes } from '../ingress/ingress-routes.js'; +import { IngressService } from '../ingress/ingress-service.js'; +import { type Shutdown } from '../lifecycle/shutdown.js'; +import { type Logger } from '../logging/logger.js'; +import { registerOverviewRoutes } from '../overview/overview-routes.js'; +import { type OverviewExtension, OverviewService } from '../overview/overview-service.js'; +import { registerParticipantsRoutes } from '../participants/participants-routes.js'; +import { ParticipantsService } from '../participants/participants-service.js'; +import { registerRoomsRoutes } from '../rooms/rooms-routes.js'; +import { RoomsService } from '../rooms/rooms-service.js'; +import { StatsModeReader } from '../stats/stats-mode.js'; +import { WriterFreshness } from '../status/freshness.js'; +import { registerStatusRoutes } from '../status/status-routes.js'; +import { StatusService } from '../status/status-service.js'; +import { registerTracksRoutes } from '../tracks/tracks-routes.js'; +import { TOKENS } from './tokens.js'; + +export interface ContainerInputs { + edition: Edition; + version: string; + /** Whether the OpenAPI document and its page are served. */ + docsEnabled: boolean; + config: DashboardConfig; + logger: Logger; + client: MongoClient; + db: Db; + shutdown: Shutdown; +} + +/** + * Binds the CE services and extension tokens and registers the CE routes. An extending edition rebinds them in its + * `configure`, before the startup hooks. + */ +export function buildContainer(inputs: ContainerInputs): Container { + const { edition, version, config, logger, client, db, shutdown } = inputs; + const container = new Container(); + + container.bind(TOKENS.Config).toConstantValue(config); + container.bind(TOKENS.Logger).toConstantValue(logger); + container.bind(TOKENS.MongoClient).toConstantValue(client); + container.bind(TOKENS.Database).toConstantValue(db); + container.bind(TOKENS.Shutdown).toConstantValue(shutdown); + container.bind<() => Date>(TOKENS.Clock).toConstantValue(() => new Date()); + container.bind(TOKENS.Features).toConstantValue([]); + container.bind<() => InfoLicense | undefined>(TOKENS.LicenseInfo).toConstantValue(() => undefined); + container.bind(TOKENS.RouteRegistry).toConstantValue(new RouteRegistry(edition)); + container.bind(TOKENS.AnalyticsCatalogue).toConstantValue(CE_ANALYTICS_CATALOGUE); + container + .bind(TOKENS.SessionRepository) + .toResolvedValue((database: Db) => new SessionRepository(database), [TOKENS.Database]) + .inSingletonScope(); + container + .bind(TOKENS.StatsModeReader) + .toResolvedValue( + (database: Db, clock: () => Date) => new StatsModeReader(database, clock), + [TOKENS.Database, TOKENS.Clock] + ) + .inSingletonScope(); + container + .bind(TOKENS.InfoService) + .toResolvedValue( + (database: Db, statsMode: StatsModeReader) => + new InfoService(database, { + edition: edition, + version: version, + features: () => container.get(TOKENS.Features), + limits: rankedSortLimits(config.lists.totalMaxRangeMs), + license: () => container.get<() => InfoLicense | undefined>(TOKENS.LicenseInfo)(), + openapiDocsEnabled: inputs.docsEnabled, + statsMode + }), + [TOKENS.Database, TOKENS.StatsModeReader] + ) + .inSingletonScope(); + container + .bind(TOKENS.AuthService) + .toResolvedValue( + (key: Uint8Array, sessions: SessionRepository, clock: () => Date) => + new AuthService({ + username: config.admin.username, + password: config.admin.password, + edition: edition, + key, + refreshTokenTtlSeconds: config.session.refreshTokenTtlSeconds, + sessions, + logger, + now: clock + }), + [TOKENS.SigningKey, TOKENS.SessionRepository, TOKENS.Clock] + ) + .inSingletonScope(); + + container + .bind(TOKENS.WriterFreshness) + .toResolvedValue( + (database: Db, clock: () => Date) => new WriterFreshness(database, clock), + [TOKENS.Database, TOKENS.Clock] + ) + .inSingletonScope(); + + container + .bind(TOKENS.RoomsService) + .toResolvedValue( + (database: Db, clock: () => Date, freshness: WriterFreshness) => + new RoomsService(database, { + totalMaxRangeMs: config.lists.totalMaxRangeMs, + freshness, + now: clock + }), + [TOKENS.Database, TOKENS.Clock, TOKENS.WriterFreshness] + ) + .inSingletonScope(); + + container + .bind(TOKENS.ParticipantsService) + .toResolvedValue( + (database: Db, clock: () => Date, freshness: WriterFreshness) => + new ParticipantsService(database, { + totalMaxRangeMs: config.lists.totalMaxRangeMs, + freshness, + now: clock + }), + [TOKENS.Database, TOKENS.Clock, TOKENS.WriterFreshness] + ) + .inSingletonScope(); + + container + .bind(TOKENS.EgressService) + .toResolvedValue( + (database: Db, clock: () => Date) => + new EgressService(database, { totalMaxRangeMs: config.lists.totalMaxRangeMs, now: clock }), + [TOKENS.Database, TOKENS.Clock] + ) + .inSingletonScope(); + + container + .bind(TOKENS.IngressService) + .toResolvedValue( + (database: Db, clock: () => Date) => + new IngressService(database, { totalMaxRangeMs: config.lists.totalMaxRangeMs, now: clock }), + [TOKENS.Database, TOKENS.Clock] + ) + .inSingletonScope(); + + container + .bind(TOKENS.AnalyticsFeatureGate) + .toResolvedValue(() => new AnalyticsFeatureGate(() => container.get(TOKENS.Features))) + .inSingletonScope(); + + container + .bind(TOKENS.AnalyticsService) + .toResolvedValue( + (database: Db, clock: () => Date, gate: AnalyticsFeatureGate, catalogue: AnalyticsCatalogue) => + new AnalyticsService(database, { + now: clock, + gate, + catalogue, + cache: new AnalyticsCache(config.analytics.cacheTtlSeconds * 1000, clock) + }), + [TOKENS.Database, TOKENS.Clock, TOKENS.AnalyticsFeatureGate, TOKENS.AnalyticsCatalogue] + ) + .inSingletonScope(); + + container.bind(TOKENS.OverviewExtension).toConstantValue(() => ({})); + container + .bind(TOKENS.OverviewService) + .toResolvedValue( + (database: Db, clock: () => Date, extension: OverviewExtension) => + new OverviewService(database, { now: clock, extension }), + [TOKENS.Database, TOKENS.Clock, TOKENS.OverviewExtension] + ) + .inSingletonScope(); + container + .bind(TOKENS.StatusService) + .toResolvedValue( + (database: Db, clock: () => Date, info: InfoService) => new StatusService(database, { info, now: clock }), + [TOKENS.Database, TOKENS.Clock, TOKENS.InfoService] + ) + .inSingletonScope(); + + logger.info( + config.analytics.cacheTtlSeconds > 0 + ? `Analytics cache: completed buckets kept for ${String(config.analytics.cacheTtlSeconds)} s` + : 'Analytics cache disabled (DASHBOARD_ANALYTICS_CACHE_TTL=0)' + ); + + const registry = container.get(TOKENS.RouteRegistry); + const now = () => container.get<() => Date>(TOKENS.Clock)(); + + registerProbes(registry, version, db); + registerAuthRoutes( + registry, + () => container.get(TOKENS.AuthService), + () => ({ + basePath: container.get(TOKENS.BasePath), + ttlSeconds: config.session.refreshTokenTtlSeconds, + secure: config.session.cookieSecure + }) + ); + registerInfoRoute(registry, () => container.get(TOKENS.InfoService)); + registerRoomsRoutes(registry, () => container.get(TOKENS.RoomsService), now); + registerParticipantsRoutes(registry, () => container.get(TOKENS.ParticipantsService), now); + registerTracksRoutes(registry); + registerEgressRoutes(registry, () => container.get(TOKENS.EgressService), now); + registerIngressRoutes(registry, () => container.get(TOKENS.IngressService), now); + registerAnalyticsRoutes(registry, () => container.get(TOKENS.AnalyticsService), now); + registerOverviewRoutes(registry, () => container.get(TOKENS.OverviewService)); + registerStatusRoutes(registry, () => container.get(TOKENS.StatusService)); + + return container; +} diff --git a/openvidu-dashboard/dashboard-ce/backend/src/lifecycle/startup.ts b/openvidu-dashboard/dashboard-ce/backend/src/lifecycle/startup.ts index 749d9a35d..d170e7ffe 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/lifecycle/startup.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/lifecycle/startup.ts @@ -1,55 +1,25 @@ import type { Server } from 'node:http'; -import { Container } from 'inversify'; +import { type Container } from 'inversify'; import { type Db, MongoClient } from 'mongodb'; -import { - type Edition, - type FeatureKey, - type InfoLicense, - 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 { type RouteRegistry } from '../api/route-registry.js'; +import { type AuthService } from '../auth/auth-service.js'; +import { bearerAuthenticator } from '../auth/bearer.js'; +import { type SessionRepository } from '../auth/session-repository.js'; +import { provisionSigningKey } from '../auth/signing-key.js'; import { loadConfigFile } from '../config/config-file.js'; import { type DashboardConfig, parseConfig } from '../config/variables.js'; +import { buildContainer } from '../di/container.js'; import { TOKENS } from '../di/tokens.js'; -import { AnalyticsCache } from '../analytics/analytics-cache.js'; -import { type AnalyticsCatalogue, CE_ANALYTICS_CATALOGUE } from '../analytics/analytics-catalogue.js'; -import { AnalyticsFeatureGate } from '../analytics/analytics-gate.js'; -import { registerAnalyticsRoutes } from '../analytics/analytics-routes.js'; -import { AnalyticsService } from '../analytics/analytics-service.js'; -import { docsRouter, openApiDocument } from '../api/openapi.js'; -import { registerProbes } from '../api/probes.js'; -import { rankedSortLimits } from '../api/ranked-limits.js'; -import { RouteRegistry } from '../api/route-registry.js'; -import { registerAuthRoutes } from '../auth/auth-routes.js'; -import { AuthService } from '../auth/auth-service.js'; -import { bearerAuthenticator } from '../auth/bearer.js'; -import { SessionRepository } from '../auth/session-repository.js'; -import { provisionSigningKey } from '../auth/signing-key.js'; -import { registerEgressRoutes } from '../egress/egress-routes.js'; -import { EgressService } from '../egress/egress-service.js'; import { createHttpApp } from '../http/app.js'; import { resolveBasePath } from '../http/base-path.js'; -import { InfoService } from '../info/info-service.js'; -import { registerIngressRoutes } from '../ingress/ingress-routes.js'; -import { IngressService } from '../ingress/ingress-service.js'; -import { registerInfoRoute } from '../info/info-routes.js'; import { createLogger, type Logger } from '../logging/logger.js'; import { mongoClientOptions, resolveMongoUrl } from '../mongo/mongo-client.js'; -import { registerOverviewRoutes } from '../overview/overview-routes.js'; -import { type OverviewExtension, OverviewService } from '../overview/overview-service.js'; -import { registerParticipantsRoutes } from '../participants/participants-routes.js'; -import { ParticipantsService } from '../participants/participants-service.js'; -import { registerRoomsRoutes } from '../rooms/rooms-routes.js'; -import { RoomsService } from '../rooms/rooms-service.js'; import { olderSchemaMessage, readSchemaStatus } from '../schema/schema-status.js'; -import { StatsModeReader } from '../stats/stats-mode.js'; -import { registerStatusRoutes } from '../status/status-routes.js'; -import { WriterFreshness } from '../status/freshness.js'; -import { StatusService } from '../status/status-service.js'; -import { registerTracksRoutes } from '../tracks/tracks-routes.js'; import { type Exit, Shutdown } from './shutdown.js'; /** Process exit codes of the dashboard; an extending edition's startup hooks may exit with codes of their own. */ @@ -203,7 +173,16 @@ export async function startDashboard(options: DashboardOptions): Promise Date>(TOKENS.Clock).toConstantValue(() => new Date()); - container.bind(TOKENS.Features).toConstantValue([]); - container.bind<() => InfoLicense | undefined>(TOKENS.LicenseInfo).toConstantValue(() => undefined); - container.bind(TOKENS.RouteRegistry).toConstantValue(new RouteRegistry(options.edition)); - container.bind(TOKENS.AnalyticsCatalogue).toConstantValue(CE_ANALYTICS_CATALOGUE); - container - .bind(TOKENS.SessionRepository) - .toResolvedValue((database: Db) => new SessionRepository(database), [TOKENS.Database]) - .inSingletonScope(); - container - .bind(TOKENS.StatsModeReader) - .toResolvedValue( - (database: Db, clock: () => Date) => new StatsModeReader(database, clock), - [TOKENS.Database, TOKENS.Clock] - ) - .inSingletonScope(); - container - .bind(TOKENS.InfoService) - .toResolvedValue( - (database: Db, statsMode: StatsModeReader) => - new InfoService(database, { - edition: options.edition, - version: options.version, - features: () => container.get(TOKENS.Features), - limits: rankedSortLimits(config.lists.totalMaxRangeMs), - license: () => container.get<() => InfoLicense | undefined>(TOKENS.LicenseInfo)(), - openapiDocsEnabled: docsEnabled(env), - statsMode - }), - [TOKENS.Database, TOKENS.StatsModeReader] - ) - .inSingletonScope(); - container - .bind(TOKENS.AuthService) - .toResolvedValue( - (key: Uint8Array, sessions: SessionRepository, clock: () => Date) => - new AuthService({ - username: config.admin.username, - password: config.admin.password, - edition: options.edition, - key, - refreshTokenTtlSeconds: config.session.refreshTokenTtlSeconds, - sessions, - logger, - now: clock - }), - [TOKENS.SigningKey, TOKENS.SessionRepository, TOKENS.Clock] - ) - .inSingletonScope(); - - container - .bind(TOKENS.WriterFreshness) - .toResolvedValue( - (database: Db, clock: () => Date) => new WriterFreshness(database, clock), - [TOKENS.Database, TOKENS.Clock] - ) - .inSingletonScope(); - - container - .bind(TOKENS.RoomsService) - .toResolvedValue( - (database: Db, clock: () => Date, freshness: WriterFreshness) => - new RoomsService(database, { - totalMaxRangeMs: config.lists.totalMaxRangeMs, - freshness, - now: clock - }), - [TOKENS.Database, TOKENS.Clock, TOKENS.WriterFreshness] - ) - .inSingletonScope(); - - container - .bind(TOKENS.ParticipantsService) - .toResolvedValue( - (database: Db, clock: () => Date, freshness: WriterFreshness) => - new ParticipantsService(database, { - totalMaxRangeMs: config.lists.totalMaxRangeMs, - freshness, - now: clock - }), - [TOKENS.Database, TOKENS.Clock, TOKENS.WriterFreshness] - ) - .inSingletonScope(); - - container - .bind(TOKENS.EgressService) - .toResolvedValue( - (database: Db, clock: () => Date) => - new EgressService(database, { totalMaxRangeMs: config.lists.totalMaxRangeMs, now: clock }), - [TOKENS.Database, TOKENS.Clock] - ) - .inSingletonScope(); - - container - .bind(TOKENS.IngressService) - .toResolvedValue( - (database: Db, clock: () => Date) => - new IngressService(database, { totalMaxRangeMs: config.lists.totalMaxRangeMs, now: clock }), - [TOKENS.Database, TOKENS.Clock] - ) - .inSingletonScope(); - - container - .bind(TOKENS.AnalyticsFeatureGate) - .toResolvedValue(() => new AnalyticsFeatureGate(() => container.get(TOKENS.Features))) - .inSingletonScope(); - - container - .bind(TOKENS.AnalyticsService) - .toResolvedValue( - (database: Db, clock: () => Date, gate: AnalyticsFeatureGate, catalogue: AnalyticsCatalogue) => - new AnalyticsService(database, { - now: clock, - gate, - catalogue, - cache: new AnalyticsCache(config.analytics.cacheTtlSeconds * 1000, clock) - }), - [TOKENS.Database, TOKENS.Clock, TOKENS.AnalyticsFeatureGate, TOKENS.AnalyticsCatalogue] - ) - .inSingletonScope(); - - container.bind(TOKENS.OverviewExtension).toConstantValue(() => ({})); - container - .bind(TOKENS.OverviewService) - .toResolvedValue( - (database: Db, clock: () => Date, extension: OverviewExtension) => - new OverviewService(database, { now: clock, extension }), - [TOKENS.Database, TOKENS.Clock, TOKENS.OverviewExtension] - ) - .inSingletonScope(); - container - .bind(TOKENS.StatusService) - .toResolvedValue( - (database: Db, clock: () => Date, info: InfoService) => new StatusService(database, { info, now: clock }), - [TOKENS.Database, TOKENS.Clock, TOKENS.InfoService] - ) - .inSingletonScope(); - - logger.info( - config.analytics.cacheTtlSeconds > 0 - ? `Analytics cache: completed buckets kept for ${String(config.analytics.cacheTtlSeconds)} s` - : 'Analytics cache disabled (DASHBOARD_ANALYTICS_CACHE_TTL=0)' - ); - - const registry = container.get(TOKENS.RouteRegistry); - const now = () => container.get<() => Date>(TOKENS.Clock)(); - - registerProbes(registry, options.version, db); - registerAuthRoutes( - registry, - () => container.get(TOKENS.AuthService), - () => ({ - basePath: container.get(TOKENS.BasePath), - ttlSeconds: config.session.refreshTokenTtlSeconds, - secure: config.session.cookieSecure - }) - ); - registerInfoRoute(registry, () => container.get(TOKENS.InfoService)); - registerRoomsRoutes(registry, () => container.get(TOKENS.RoomsService), now); - registerParticipantsRoutes(registry, () => container.get(TOKENS.ParticipantsService), now); - registerTracksRoutes(registry); - registerEgressRoutes(registry, () => container.get(TOKENS.EgressService), now); - registerIngressRoutes(registry, () => container.get(TOKENS.IngressService), now); - registerAnalyticsRoutes(registry, () => container.get(TOKENS.AnalyticsService), now); - registerOverviewRoutes(registry, () => container.get(TOKENS.OverviewService)); - registerStatusRoutes(registry, () => container.get(TOKENS.StatusService)); - - return container; -}