LMS Integration

This document describes the pluggable LMS (Learning Management System) integration in Notebooks Hub: how the architecture fits together, how configuration and routing work, and how to add a new LMS provider.

Overview

The LMS stack is a gateway over vendor REST APIs. Runtime wiring is driven by MongoDB config. Application code never calls Docebo (or a future vendor) directly—it uses a normalized LmsGateway contract.

Main consumers:

  • Admin APIs / Admin Console — CRUD for LMS implementations, active route, and live course lookup

  • LmsSyncService — maps LMS course enrollments to OpenFGA group membership

  • JupyterHub user init — looks up courses for the signed-in user via the router

Only one provider type is shipped today (docebo). Additional vendors are added by implementing a datasource + adapter and registering them in the factory and registry (see Adding a new LMS datasource).

Architecture

flowchart TB subgraph consumers [Consumers] Admin["LmsAdminController"] SyncCtrl["LmsSyncController"] JH["JupyterHubController"] end subgraph routing [Routing and registry] Router["LmsRouterService"] RouteRepo["LmsRouteConfigRepository"] Registry["DynamicLmsRegistry"] ImplRepo["LmsImplementationConfigRepository"] Tokens["LmsTokenManager"] end subgraph provider [Per-implementation stack] Factory["createLmsGateway"] DS["REST DataSource"] Adapter["Vendor adapter"] Vendor["Vendor LMS API"] end subgraph sideeffects [Side effects] Sync["LmsSyncService"] FGA["OpenFGA tuples"] end Admin --> ImplRepo Admin --> RouteRepo Admin --> Registry Admin --> Router SyncCtrl --> Sync JH --> Router Sync --> Router Sync --> ImplRepo Sync --> FGA Router --> RouteRepo Router --> Registry Registry --> ImplRepo Registry --> Tokens Registry --> DS Registry --> Factory Factory --> Adapter Adapter --> DS DS --> Vendor Tokens -->|OAuth token POST| Vendor

Request path

  1. Caller uses LmsRouterService with an optional implementationKey, or the active route key.

  2. Router asks DynamicLmsRegistry.getGateway(key).

  3. Registry loads LmsImplementationConfig, rejects disabled implementations (503), and obtains an Authorization header from LmsTokenManager.

  4. On cache miss (or config/auth signature change), the registry builds a LoopBack REST datasource, wraps it with createLmsGateway, and caches the result.

  5. Caller invokes normalized LmsGateway methods.

Gateway contract

Defined in [packages/API/src/services/lms-gateway.types.ts](../../../packages/API/src/services/lms-gateway.types.ts):

export interface LmsGateway {
  getUsers(username?: string): Promise<LmsUser[]>;
  getCourses(name?: string): Promise<LmsCourse[]>;
  getUsersByCourse(courseName: string): Promise<string[]>;
  getCoursesByUser(username: string): Promise<LmsCourse[]>;
}

Semantics implementers must match:

  • getUsers / getCourses return normalized LmsUser / LmsCourse models.

  • getUsersByCourse returns deduped lowercase emails/usernames (string[]), not LmsUser[]. The parameter is the provider’s course identifier (sync passes course ids for Docebo despite the courseName name).

  • getCoursesByUser takes a username/email, resolves any vendor user id if needed, and returns courses.

Vendor JSON stays behind the adapter. REST datasources expose raw clients (Promise<unknown> or vendor-specific types); only adapters are application-facing.

Key components

Layer

Location

Role

Contract

services/lms-gateway.types.ts

LmsGateway interface

Factory

services/lms/lms-gateway.factory.ts

implementationType → adapter

Registry

services/dynamic-lms-registry.service.ts

Build/cache datasources + gateways; invalidate on config change

Router

services/lms-router.service.ts

Resolve active (or override) key; delegate gateway methods

Token manager

services/lms-token-manager.service.ts

OAuth-style token fetch/cache; bearer header

Sync

services/lms-sync.service.ts

Course enrollments → OpenFGA group members

Docebo DS

datasources/lms/docebo-lms.datasource.ts

REST operations + buildDoceboLmsDataSource

Docebo adapter

services/lms/adapters/docebo-lms-gateway.adapter.ts

Normalize Docebo payloads

Admin API

controllers/lms-admin.controller.ts

JWT-protected config CRUD and route

Sync API

controllers/lms-sync.controller.ts

POST /lms/update/courses

Services are registered as singletons in application.ts (LmsTokenManager, DynamicLmsRegistry, LmsRouterService, LmsSyncService).

Configuration and routing

Implementation config (lms_implementation_config)

Field

Meaning

name

Stable key (path param {key}); unique

implementationType

e.g. docebo (LmsImplementation enum)

baseUrl

Vendor API base URL

auth

Nested LmsAuthConfig (see below)

enabled

false → gateway calls return 503

courses

LmsCourseGroupMapping[] used by sync (coursegroup)

Route config (lms_route_config)

Singleton document with activeImplementationKey. If missing, the repository auto-creates one (default key default).

  • PUT /lms/route requires the target implementation to exist.

  • Deleting an implementation that is currently active is blocked; switch the route first.

  • Changing the active route does not invalidate gateway caches (caches are keyed by implementation, not by “active”).

Auth and tokens

LmsTokenManager obtains a bearer token for each implementation:

  1. If a cached token for the auth signature is still valid, reuse it.

  2. Otherwise POST a JSON grant payload to auth.authUrl (grant_type, optional client_id / client_secret / username / password / scope).

  3. Cache access_token using expires_in (default TTL 3600s). Concurrent refreshes per key are deduped.

The authorization header is baked into the REST datasource at build time. When the token or auth config changes, the registry signature changes and the datasource is rebuilt.

New implementations require auth.authUrl. On update, empty/omitted secret fields (token, password, clientSecret) are preserved from the previous document so the admin UI can omit them on edit.

Today only simple credential-based grants are fully implemented. Roadmap includes implementation of static API tokens and complex multi-step authentication flows (i.e., OAuth2.0).

Webhook Sync

POST /lms/update/coursesLmsSyncService.updateCourses():

  1. Resolve the active implementation key and load its courses mappings.

  2. For each mapping, call getUsersByCourse(course.id).

  3. For each returned email, create an OpenFGA member tuple for the mapped group.id (409 treated as already-member success).

  4. Courses are processed with Promise.allSettled so one mapping failure does not abort the others at the outer level.

Course→group mappings live on the implementation config; they are not part of the gateway interface itself.

Admin REST surface

All admin routes use @authenticate('oauth-jwt'):

Method

Path

Behavior

GET

/lms/implementations

List configs

GET

/lms/implementations/{key}

One config

GET

/lms/implementations/{key}/courses

Live courses via router (?name= optional)

PUT

/lms/implementations/{key}

Upsert; invalidates registry for that key

DELETE

/lms/implementations/{key}

Delete if not active route; invalidate

GET

/lms/route

Active route

PUT

/lms/route

Set { activeImplementationKey }

Sync:

Method

Path

Behavior

POST

/lms/update/courses

Run course→group sync

The admin Angular client lives in [packages/UI/src/app/services/lms/lms-admin.service.ts](../../../packages/UI/src/app/services/lms/lms-admin.service.ts).

Adding a new LMS datasource

Use the Docebo stack as the template. You do not need a new LoopBack injectable service per vendor if you follow the factory + adapter pattern; registration is the switch cases plus Mongo config.

1. Extend the implementation type enum

Add a value to LmsImplementation in [packages/API/src/models/lms/lms-implementation-config.model.ts](../../../packages/API/src/models/lms/lms-implementation-config.model.ts):

export const LmsImplementation = {
  Docebo: 'docebo',
  Acme: 'acme', // example
} as const;

Mirror the same constant and LMS_IMPLEMENTATION_TYPES in the UI ([lms-admin.service.ts](../../../packages/UI/src/app/services/lms/lms-admin.service.ts)). Keep backend and UI enums aligned.

2. Vendor client types

Add packages/API/src/datasources/lms/<vendor>-lms.client.types.ts with the raw REST client shape (four methods matching your operation templates, typically returning Promise<unknown>).

3. DataSource builder

Add packages/API/src/datasources/lms/<vendor>-lms.datasource.ts following buildDoceboLmsDataSource:

  • LoopBack connector: 'rest'

  • Operation templates (RestOperationDefinition) for the four gateway operations

  • Pass authorizationHeader into default headers (same snapshot pattern as Docebo)

  • Export a build… helper and optional fromLmsImplementationConfig mapper

Export the new modules from [packages/API/src/datasources/lms/index.ts](../../../packages/API/src/datasources/lms/index.ts).

4. Gateway adapter

Add packages/API/src/services/lms/adapters/<vendor>-lms-gateway.adapter.ts implementing LmsGateway:

  • Map vendor payloads → LmsUser (userId, username, …) and LmsCourse (courseId, name, …)

  • getUsersByCourse → deduped lowercase emails/usernames

  • Resolve username → vendor user id when the upstream API requires ids

Export from [packages/API/src/services/lms/adapters/index.ts](../../../packages/API/src/services/lms/adapters/index.ts).

5. Register in factory and registry

In [lms-gateway.factory.ts](../../../packages/API/src/services/lms/lms-gateway.factory.ts):

case LmsImplementation.Acme:
  return new AcmeLmsGatewayAdapter(rawClient as AcmeLmsClient);

In DynamicLmsRegistry.buildDataSource:

case LmsImplementation.Acme:
  return buildAcmeLmsDataSource({
    ...fromLmsImplementationConfig(config, `lms-${implementationKey}`),
    authorizationHeader,
  });

Unsupported types should continue to throw HttpErrors.NotImplemented.

6. Configure and activate

Via admin API (or Admin Console):

  1. PUT /lms/implementations/{key} with implementationType, baseUrl, auth (include authUrl), optional courses mappings, enabled: true.

  2. PUT /lms/route with { "activeImplementationKey": "{key}" }.

  3. Optionally browse live courses: GET /lms/implementations/{key}/courses.

  4. Run sync when mappings are ready: POST /lms/update/courses.

7. Tests

Mirror existing unit tests under packages/API/src/__tests__/unit/ for:

  • datasource builder / client types

  • adapter normalization

  • registry buildDataSource switch / cache invalidation

Gotchas

  • Two-layer client: Keep vendor JSON parsing in adapters, not controllers or sync.

  • Docebo domain model: Docebo “courses” map to orgchart branches (/manage/v1/orgchart…), not a separate learning-course catalog.

  • Datasource auth is snapshot-based: Token is fixed in DS headers until the registry signature changes and rebuilds.

  • Secret-preserving upsert: Empty secret fields on update do not wipe stored values.

  • Cannot delete the active implementation: Switch PUT /lms/route first.

  • Enum duplication: Backend and Angular both define LmsImplementation; update both when adding a provider.