---
status: walked + e2e-covered
coverage_depth: deep
last_walked:
  '2026-07-16 by Codex — reconciled to the current operator page, connector
  runtime, OneRoster preview seam, LTI fail-closed path, and real-dev-infra
  Playwright coverage.'
specs:
  - apps/oshun/web/e2e/lms-connectors.spec.ts
  - apps/oshun/web/e2e/operator-lms.spec.ts
---

# Journey: LMS connectors — operator visibility and validation

An operator can inspect the live LMS connector roster, see every rejected
environment descriptor, dry-run a candidate, and create or remove an in-process
managed override. The same page can preview a OneRoster import without applying
it. LTI launch then proves the important runtime boundary: an unavailable JWKS
fails closed instead of accepting an unverifiable token.

## Personas

- **Platform operator** — has `admin:*` or `admin:workspace:tenant-console`.
- **Tenant LMS administrator** — supplies issuer, client, deployment, endpoint,
  and scope values from the tenant's Canvas, Moodle, or other LMS registration.

## Pre-conditions

- The operator has an authenticated admin session.
- The external LMS tool registration already exists; Oshun does not create that
  third-party registration from `/operator/lms`.
- Environment-seeded connectors, when used, arrive through
  `OSHUN_LMS_CONNECTORS`.
- Managed connector writes are understood to be runtime-local. The current
  `InMemoryTenantLmsConnectorAdminStore` has no snapshot sink.

## Steps

### 1. Inspect boot validation

- Open `/operator/lms`.
- The server component reads `GET /v1/admin/lms/connectors` and renders each
  valid connector with tenant, connector id, provider, source, status,
  environment, capabilities, and secret-free LTI summary.
- Invalid environment descriptors appear before the roster with their exact
  code, path, severity, and message. Malformed JSON appears as
  `connectors_json_invalid`; invalid entries are not registered.
- With no readable roster, the page fails soft to its empty state. The BFF
  itself still returns `401`/`403` for callers without the required scope.

### 2. Dry-run a descriptor

- Paste a candidate descriptor into the validation form.
- The browser posts to `POST /v1/admin/lms/connectors/validate`.
- Missing identity fields return `400 invalid_request`. A structurally accepted
  but invalid descriptor returns `200 { valid: false, issues }`; a valid
  descriptor returns `200 { valid: true, issues: [] }`.
- This route does not register or persist the candidate.

### 3. Create or replace a managed connector

- The managed connector panel parses the descriptor and posts it to
  `POST /v1/admin/lms/connectors`.
- The BFF validates before mutating the runtime. An invalid descriptor returns
  `422 invalid_lms_connector`.
- A new key is `created`; a repeated managed key is `updated`; a managed row
  with the same `{tenantId, connectorId}` as an environment row is
  `overrode_env`.
- The refreshed roster labels the active row `managed override` and never
  returns connector secrets.

### 4. Delete and restore

- Delete through `DELETE /v1/admin/lms/connectors/:tenantId/:connectorId`.
- Deleting a managed override restores the matching environment descriptor when
  one exists.
- Deleting an environment-only connector creates an in-memory tombstone and
  unregisters it for the rest of the current process. A restart or
  reconfiguration can restore that environment descriptor.
- An unknown key returns `404 lms_connector_not_found`.

### 5. Preview a OneRoster import

- Paste `{ tenantId, incomingUsers, incomingEnrollments }` into the OneRoster
  preview form.
- The browser posts to `POST /v1/admin/lms/oneroster/preview` and renders
  create, update, enrollment, and conflict rows from the current roster diff.
- `canApply` and the projected before/after counts describe what a separate
  apply request could do. This page exposes no apply control and the preview
  itself does not mutate the roster.

### 6. Exercise the tenant launch boundary

- `POST /v1/tenant/lms/lti/login` resolves the requested registered connector
  and creates OIDC state and nonce.
- The launch route verifies the returned `id_token` against the configured
  platform JWKS. An unreachable JWKS returns `lti_jwks_unavailable`; no
  unverified identity is accepted.
- SCORM routes are available only when the registered descriptor enables the
  relevant SCORM capability.

## Post-conditions

- Every active connector has one visible source: `env` or `managed`.
- Invalid boot descriptors remain operator-visible but absent from the launch
  registry.
- A successful dry run has made no runtime mutation.
- A OneRoster preview has made no roster mutation.
- Managed overrides and environment tombstones survive only for the current BFF
  process.
- The covered launch attempt fails closed when its configured JWKS cannot be
  fetched.

## Failure modes

- **Malformed environment JSON** — no connector registers; the invalid panel
  surfaces `connectors_json_invalid`.
- **Invalid descriptor** — validation returns ordered issues and managed create
  performs no mutation.
- **Missing or wrong admin scope** — read, validate, create, delete, and
  OneRoster preview return `401`/`403`.
- **Unavailable BFF from the server component** — the page shows the honest
  empty state; this must not be mistaken for a proven zero-connector roster.
- **Delete an environment connector** — the runtime tombstone is not durable
  across restart.
- **OneRoster conflicts** — preview reports `canApply: false`; this surface does
  not offer a bypass.
- **JWKS outage** — LTI launch fails closed.

## E2E coverage

- [`apps/oshun/web/e2e/lms-connectors.spec.ts`](../../apps/oshun/web/e2e/lms-connectors.spec.ts)
  drives the real BFF list, validation, managed create/update/delete,
  environment override/restore, environment tombstone, secret-free read-back,
  auth gates, and LTI login-to-launch JWKS failure.
- [`apps/oshun/web/e2e/operator-lms.spec.ts`](../../apps/oshun/web/e2e/operator-lms.spec.ts)
  renders the operator page from the real seeded BFF roster, asserts the invalid
  descriptor evidence, exercises malformed/invalid/valid validation, creates and
  removes a managed connector, and previews both clean and conflicting OneRoster
  payloads without applying them.
- Unit coverage in `lms-route.test.ts`, the operator page tests, and the two
  connector form suites pins the store and component branches.

## Per-view files touched

- [`../operator/operator-lms.md`](../operator/operator-lms.md) — primary roster,
  validation, managed connector, and OneRoster preview surface.
- [`../operator/operator-tenants.md`](../operator/operator-tenants.md) — tenant
  context.
- [`../operator/operator-sso.md`](../operator/operator-sso.md) — adjacent
  identity configuration.

## Cross-references

- `V1_DEPLOYMENT_REQUIREMENTS.md` §3 — environment descriptor examples.
- `libs/shared/inbound-integrations/src/lms.ts` — descriptor creation,
  validation, LTI, and SCORM contracts.
- `apps/oshun/bff/src/tenant-console/lms-route.ts` — connector runtime and admin
  routes.
- `apps/oshun/bff/src/routes/admin-oneroster-apply.ts` — preview and the
  separate apply boundary.

## Open questions

- [ ] Replace `InMemoryTenantLmsConnectorAdminStore` with durable, tenant-scoped
      connector storage and audited mutations.
- [ ] Define ownership and reconciliation for external LMS-side registration.
- [ ] Decide whether `/operator/lms` should expose the already-shipped OneRoster
      apply route, including commit id and conflict confirmation.
- [ ] Add a live third-party LMS conformance environment; local coverage proves
      fail-closed JWKS behavior, not successful provider interoperability.
