---
path: /operator/lms
surface: operator
domain: system
auth: signed-in
source: apps/oshun/web/src/app/operator/lms/page.tsx
status: walked + e2e-covered
last_walked:
  '2026-06-30 by Codex (managed connector CRUD overlay added and covered through the real admin BFF), against commit HEAD'
  '2026-06-28 by Codex (OneRoster import diff preview table added and covered through the real admin BFF), against commit HEAD'
  '2026-06-25 by Codex (generatedAt/count/card/field row-cell anchors added to the live roster and invalid panel), against commit HEAD'
  '2026-06-25 by Codex (validator disabled/live-region states, proxy gate, and footer tap targets covered), against commit HEAD'
  '2026-06-22 by Codex (drafted from source), against commit HEAD'
---

# Operator · LMS connectors

## Purpose

Operator view of the tenant LMS connector roster (3.3). It reads
`GET /v1/admin/lms/connectors` (seeded from `OSHUN_LMS_CONNECTORS` at boot plus
admin-managed descriptors), surfaces a **prominent invalid-connector panel**
listing every descriptor skipped at boot with its exact validation issues
(ending the silent-skip era), renders the live connector roster (provider,
status, source, environment, LTI 1.3 / SCORM), offers a **managed connector
panel** that can create, override, delete, and restore runtime descriptors
through the admin BFF, offers a **OneRoster import diff form** that POSTs a
pasted candidate roster payload to `POST /v1/admin/lms/oneroster/preview` and
renders a row-level no-mutation diff table, and offers a **dry-run validate
form** that POSTs a pasted candidate descriptor to
`POST /v1/admin/lms/connectors/validate` without persisting.

## Entry points

- Linked from `/operator/sso` via the sibling **SSO** ghost button in the foot
  row (`/operator/lms` links onward to `/operator/sso`; the back-link is
  reciprocal across the operator hub).
- Foot-row links from this page navigate onward — **← Operator hub**
  (`/operator`), **SSO** (`/operator/sso`), and primary **Tenants**
  (`/operator/tenants`).
- Direct URL (bookmarkable) — `metadata.alternates.canonical = '/operator/lms'`.
  Session-gated only via `proxy.ts` (the path is not in `PUBLIC_PATHS` /
  `PUBLIC_PREFIXES`); the BFF GET itself is admin-gated, so a non-admin session
  degrades to the empty state via fail-soft `bffGet`.
- Not crawlable — `metadata.robots = { index: false, follow: false }`.

## Layout regions

Async server component (`OperatorLmsPage`) that awaits
`bffGet('/v1/admin/lms/connectors')` (falls back to
`{ generatedAt: now, connectors: [], invalid: [] }`), rendered inside
`<LWebShell top={<LCustomerNav active="library" />}>` with a column at
`maxWidth: 1080` and `padding: '32px 56px 80px'` on a `[data-responsive-page]`
wrapper. The wrapper also carries `data-operator-lms-page=""` and
`data-operator-lms-generated-at="{iso}"` so the route fetch envelope is
browser-verifiable.

- **Header**: shell header — `LWebShell` with `LCustomerNav active="library"`
  (top slot).
- **Masthead**: `LMasthead` — `left="Operator · LMS"`,
  `right="{N} connector{s}"` (pluralized off `connectors.length`),
  `kicker="Tenant integrations"`, `title` = "The _LMS_ roster." (the word "LMS"
  is italicized via an inner `<em>`). Its wrapper carries
  `data-operator-lms-masthead=""`,
  `data-operator-lms-connector-count="{connectors.length}"`, and
  `data-operator-lms-invalid-count="{invalid.length}"`.
- **Invalid-connector panel** — rendered FIRST and prominently, only when
  `invalid.length > 0`. Carries `data-lms-invalid-panel="{count}"`, an
  `data-operator-lms-invalid-panel=""`, and
  `data-operator-lms-invalid-count="{invalid.length}"` on an `L.alert`-bordered
  card on `L.paper`. It shows an uppercase mono heading "{N} connector{s}
  skipped at boot — fix and redeploy", then one block per invalid descriptor
  with `data-operator-lms-invalid-card=""`,
  `data-operator-lms-invalid-connector-id`,
  `data-operator-lms-invalid-tenant-id` (`none` when absent), and
  `data-operator-lms-invalid-issue-count`. Individual issues carry
  `data-operator-lms-invalid-issue=""`, `data-operator-lms-invalid-issue-code`,
  `data-operator-lms-invalid-issue-path`, and
  `data-operator-lms-invalid-issue-severity`, while visible text remains
  `[{severity}] {path}: {message} ({code})`.
- **Connector roster** (main) — a `display: grid` of cards (`marginTop: 36`).
  The wrapper carries `data-operator-lms-roster=""` and
  `data-operator-lms-roster-count="{connectors.length}"`. Each card carries
  `data-operator-lms-card=""`, connector/tenant/provider/status/environment
  attributes, `data-operator-lms-source="{env|managed}"`, capability count, LTI
  1.3 presence/deployment count, and SCORM presence. The visible fields are also
  named: `data-operator-lms-field="tenant-eyebrow"` for "Tenant · {tenantId} ·
  {connectorId}", `"display-name"` for the serif name, `"provider-status"` for
  "{providerKey} · {status} · {environment}", `"source"` for "env descriptor" or
  "managed override", `"lti13"` for the optional issuer / client / deployment
  line, and `"capabilities"` for "{cap · cap}" or "no capabilities declared"
  plus optional " · SCORM". Card border remains `L.accent` when
  `status === 'active'`, else `L.rule`.
- **Empty state** — when `connectors.length === 0`: a dashed-border card reading
  "No LMS connectors configured. Set `OSHUN_LMS_CONNECTORS` or create a managed
  descriptor below to onboard a tenant platform." and carrying
  `data-operator-lms-empty=""`.
- **Managed connector CRUD** — `<LmsConnectorCrudPanel />` client island (below
  the connector roster). It carries `data-lms-crud-panel=""`,
  `data-lms-crud-state="{idle|saving|deleting|success|error}"`, and
  `data-lms-crud-connector-count="{connectors.length}"`; shows a managed/env
  source count via `data-lms-crud-source-summary`; accepts descriptor JSON,
  saves through `POST /v1/admin/lms/connectors`, deletes through
  `DELETE /v1/admin/lms/connectors/:tenantId/:connectorId`, and refreshes the
  server roster after a successful mutation.
- **OneRoster import diff** — `<OneRosterDiffPreviewForm />` client island
  (below managed connector CRUD). It carries
  `data-oneroster-diff-preview-form=""`, accepts normalized OneRoster preview
  JSON, POSTs the parsed object to `/v1/admin/lms/oneroster/preview`, and
  renders a row-level table with stable `data-oneroster-diff-*` anchors for user
  creates, user updates, enrollment creates, and blocked conflicts.
- **Dry-run validate form** — `<LmsConnectorValidateForm />` client island
  (below the OneRoster diff preview).
- **Footer action row** (`marginTop: 36`, flex-wrap): ghost **← Operator hub** →
  `/operator`, ghost **SSO** → `/operator/sso`, primary **Tenants** →
  `/operator/tenants`; wrapper carries `data-operator-lms-footer=""`.

## States

- [x] **Loading** — N/A in-page beyond RSC: the page is an async server
      component (`await bffGet(...)`); HTML streams only after the fetch
      resolves. No client skeleton/spinner exists in this file. (The validate
      form has its own in-button "Validating…" busy label — see Interactions.)
- [x] **Empty** — `connectors.length === 0` → dashed-border card "No LMS
      connectors configured. Set `OSHUN_LMS_CONNECTORS` or create a managed
      descriptor below to onboard a tenant platform." Reachable when the env
      JSON is unset and no managed descriptor exists (or a non-admin session
      degrades the read to the fallback `{ connectors: [], invalid: [] }`).
- [x] **Populated (short)** — 1–5 connectors → roster cards as described; the
      masthead `right` shows "{N} connectors".
- [ ] **Populated (long)** — 50+ connectors → plain CSS-grid stacking, no
      pagination/virtualization in source; all rows render. The invalid panel
      likewise lists every skipped descriptor with no truncation.
- [x] **Error (recoverable)** — N/A for the server read: `bffGet` returns `null`
      on any network failure / non-2xx, which the page coerces to the empty
      state (no retry affordance). For the validate form, a transient failure
      shows "Could not validate the descriptor — the service is unavailable."
      and the operator can press **Validate** again (the textarea content is
      preserved). The CRUD panel shows "Could not save the connector
      descriptor - the service is unavailable." or "Could not delete the
      connector - the service is unavailable." and preserves the
      operator-entered JSON/ids. For the OneRoster preview form, a transient
      failure shows "Could not preview the OneRoster diff - the service is
      unavailable." and preserves the textarea. No automatic retry.
- [x] **Error (unrecoverable)** — N/A: there is no 500/403 error screen in this
      page. A 403 on the GET silently degrades to the empty state (no
      information leak, but also no "you lack access" copy). A 403 on the
      validate POST surfaces inline copy "Validating a connector requires
      admin:\* or tenant-console operator scope." A 403 on CRUD save/delete
      surfaces inline copy "Managing LMS connectors requires tenant-console
      admin scope." A 403 on the OneRoster preview POST surfaces inline copy
      "Previewing OneRoster diffs requires admin:\* or tenant-console operator
      scope." Navigation away always works (foot-row links are plain
      `LBtn href`).
- [ ] **Offline** — N/A handled by this page: there is no offline banner or
      SW-write queue in source. The server render won't reach the client while
      offline; the client CRUD/validate POST would reject and fall through to
      the generic "service is unavailable" copy; the OneRoster preview POST
      behaves the same way.
- [x] **Gated** — the route requires a session (`proxy.ts` 307→/welcome when
      unauthenticated). A signed-in non-admin is NOT hard-gated by the page —
      the admin-scoped BFF read fails-soft to the empty state, so the operator
      sees the empty card rather than a gate/permission notice. Only the
      validate and CRUD POST/DELETE paths expose the scope requirement with
      inline 403 copy.
- [ ] **Standalone PWA** — N/A specific handling: the page renders in the shared
      `LWebShell` chrome; no PWA-specific affordance or safe-area code exists in
      this file.

## Interactions

### Managed connector CRUD (`LmsConnectorCrudPanel`, client island)

Source: `apps/oshun/web/src/components/lilith/LmsConnectorCrudPanel.tsx`.
Wrapper carries `data-lms-crud-panel=""`; eyebrow reads "Manage connector
descriptors".

- [x] **Managed connector descriptor JSON** (textarea)
  - Function: holds a full `LmsConnectorDescriptor` JSON object. `onChange`
    updates local `descriptorJson` state. The placeholder shows a Canvas sandbox
    example.
  - Keyboard: standard textarea editing; full-width with a stable 10-row height.
  - Screen reader: announces via
    `aria-label="Managed connector descriptor JSON"`.
  - Touch target: full-width textarea (`width: 100%`, `padding: 10`), exceeds
    44×44.
  - Disabled when: never disabled.
  - Offline behavior: editing is local-only; no network until **Save
    connector**.
  - Telemetry: none in source. Test hook: `data-testid="lms-crud-input"`.
- [x] **Save connector** (button, `button[data-lms-crud-save]`)
  - Function: parses the textarea as JSON; parse errors show "The connector
    descriptor is not valid JSON." and make no request. Valid JSON POSTs the
    parsed object to `/v1/admin/lms/connectors`; successful responses set the
    delete fields from the saved connector, show "Connector {state}. Roster
    refreshed.", and call `router.refresh()`.
  - Keyboard: activated by Enter/Space when focused.
  - Screen reader: announces "Save connector" or "Saving"; carries
    `aria-disabled` and `aria-busy` during pending work.
  - Touch target: native button with `minHeight: 44`.
  - Disabled when: a save/delete is in flight or the descriptor textarea is
    empty.
  - Offline behavior: the POST rejects → generic "Could not save the connector
    descriptor - the service is unavailable." inline error.
  - Telemetry: none in source.
- [x] **Delete tenant / connector fields** (inputs)
  - Function: hold the `{tenantId, connectorId}` used by the delete route. They
    initialize from the first live connector and update to the connector
    returned by a successful save.
  - Keyboard: standard text input editing.
  - Screen reader: visible labels are "Tenant" and "Connector"; test hooks are
    `data-testid="lms-crud-delete-tenant"` and
    `data-testid="lms-crud-delete-connector"`.
  - Touch target: `minHeight: 44`, `minWidth: 180`.
- [x] **Delete** (button, `button[data-lms-crud-delete]`)
  - Function: DELETEs `/v1/admin/lms/connectors/:tenantId/:connectorId`.
    Deleting a managed connector removes it; deleting a managed override
    restores the env descriptor with the same key; deleting an env-only
    connector tombstones it for the current runtime. Success shows "Connector
    {state}. Roster refreshed." and calls `router.refresh()`.
  - Keyboard: activated by Enter/Space when focused.
  - Screen reader: announces "Delete" or "Deleting"; carries `aria-disabled` and
    `aria-busy` during pending work.
  - Touch target: native button with `minHeight: 44`.
  - Disabled when: a save/delete is in flight or either delete field is empty.
  - Offline behavior: the DELETE rejects → generic "Could not delete the
    connector - the service is unavailable." inline error.
  - Telemetry: none in source.
- [x] **CRUD status** (text, conditional)
  - Function: shows pending, success, or error copy with
    `data-lms-crud-status="{saving|deleting|success|error}"`. 400 maps to
    "Tenant, connector, and provider fields are required.", 403 maps to
    "Managing LMS connectors requires tenant-console admin scope.", 404 maps to
    "That connector is not registered for this tenant.", and 422 maps to "The
    descriptor is not valid and was not saved."
  - Screen reader: `role="status"` for non-error states, `role="alert"` for
    error, with `aria-live="polite"` and `aria-atomic="true"`.

### OneRoster import diff preview (`OneRosterDiffPreviewForm`, client island)

Source: `apps/oshun/web/src/components/lilith/OneRosterDiffPreviewForm.tsx`.
Wrapper carries `data-oneroster-diff-preview-form=""`; eyebrow reads "OneRoster
import diff".

- [x] **OneRoster preview JSON** (textarea)
  - Function: holds `{ tenantId, incomingUsers, incomingEnrollments }` JSON.
    `onChange` updates local `payloadJson` state. The placeholder shows a
    normalized user/enrollment example.
  - Keyboard: standard textarea editing; full-width with a stable 9-row height.
  - Screen reader: announces via `aria-label="OneRoster preview JSON"`.
  - Touch target: full-width textarea (`width: 100%`, `padding: 10`), exceeds
    44×44.
  - Disabled when: never disabled.
  - Offline behavior: editing is local-only; no network until **Preview diff**.
  - Telemetry: none in source. Test hook: `data-testid="oneroster-diff-input"`.
- [x] **Preview diff** (button, `button[data-oneroster-diff-submit]`)
  - Function: on click, parses the textarea as JSON; parse errors show "The
    OneRoster preview payload is not valid JSON." and make NO request. Valid
    JSON POSTs the parsed object to `/v1/admin/lms/oneroster/preview`, which
    diffs against the committed tenant roster without mutation.
  - Keyboard: activated by Enter/Space when focused.
  - Screen reader: announces "Preview diff" or "Previewing..." while `busy`;
    carries `aria-disabled` and `aria-busy` during pending work.
  - Touch target: native button with `minHeight: 44`; covered by the route spec.
  - Disabled when: `busy` is true OR `payloadJson.trim()` is empty — native
    `disabled` and `aria-disabled` are set.
  - Offline behavior: the POST rejects → generic "Could not preview the
    OneRoster diff - the service is unavailable." inline error.
  - Telemetry: none in source.
- [x] **Diff error notice** (text, conditional)
  - Function: shows the current `error` string in `L.alert` via `LEyebrow`.
    Possible strings: invalid JSON, missing required preview fields (400),
    missing admin scope (403), and unavailable service (other/network).
  - Screen reader: `role="alert"` with `aria-atomic="true"`.
  - Test hook: `data-testid="oneroster-diff-error"`.
- [x] **Diff result table** (region/table, conditional)
  - Function: renders `{ preview }` from the real BFF. The panel carries
    `data-testid="oneroster-diff-result"`,
    `data-oneroster-diff-can-apply="{true|false}"`, and
    `data-oneroster-diff-row-count="{rows.length}"`. The summary carries
    created/updated/enrollment/conflict counts. The table exposes Entity,
    Action, Source, Before, and After columns. Each row carries
    `data-oneroster-diff-row=""`, `data-oneroster-diff-row-kind`,
    `data-oneroster-diff-row-action`, and `data-oneroster-diff-row-sourced-id`.
  - Clean preview rows render `create` / `update` user changes and enrollment
    creates; blocked previews render `conflict` rows with action `block`.
  - Screen reader: result wrapper uses `role="status"`, `aria-live="polite"`,
    and `aria-atomic="true"`.

### Dry-run validate form (`LmsConnectorValidateForm`, client island)

Source: `apps/oshun/web/src/components/lilith/LmsConnectorValidateForm.tsx`.
Wrapper carries `data-lms-validate-form=""`; eyebrow reads "Dry-run validate a
connector descriptor".

- [ ] **Connector descriptor JSON** (textarea)
  - Function: holds the candidate descriptor JSON the operator pastes;
    `onChange` updates local `descriptorJson` state. `rows={8}`, vertically
    resizable. Shows a multi-line placeholder example (canvas-prod /
    pacific-seminary / canvas / "Canvas · Pacific Seminary" / production).
  - Keyboard: standard textarea editing; first focusable control in the island.
  - Screen reader: announces via `aria-label="Connector descriptor JSON"`.
  - Touch target: full-width textarea (`width: 100%`, `padding: 10`), exceeds
    44×44.
  - Mobile (≤ 640 px): full-width; no layout delta in source.
  - Disabled when: never disabled.
  - Offline behavior: editing is local-only; no network until **Validate**.
  - Telemetry: none in source. Test hook: `data-testid="lms-validate-input"`.
- [x] **Validate** (button, `button[data-lms-validate-submit]`)
  - Function: on click, parses the textarea as JSON; on a parse error sets the
    inline error "The descriptor is not valid JSON." and makes NO request.
    Otherwise POSTs the parsed body to `/v1/admin/lms/connectors/validate` via
    `api.post` and renders the `{ valid, issues[] }` result.
  - Keyboard: activated by Enter/Space when focused (standard button).
  - Screen reader: announces its visible label — "Validate", or "Validating…"
    while `busy`; carries `aria-disabled` and `aria-busy` during pending work.
  - Touch target: native button with `minHeight: 44`; covered by the route spec.
  - Mobile (≤ 640 px): no delta in source.
  - Disabled when: `busy` is true OR `descriptorJson.trim()` is empty — native
    `disabled` and `aria-disabled` are set. Label flips to "Validating…" while a
    request is in flight.
  - Offline behavior: the POST rejects → generic "Could not validate the
    descriptor — the service is unavailable." inline error.
  - Telemetry: none in source.
- [x] **Validation error notice** (text, conditional)
  - Function: shows the current `error` string in `L.alert` via `LEyebrow`.
    Possible strings: "The descriptor is not valid JSON." (client parse),
    "Validating a connector requires admin:\* or tenant-console operator scope."
    (403), "The descriptor is missing required fields (connectorId, tenantId,
    providerKey)." (400), "Could not validate the descriptor — the service is
    unavailable." (other/network). Test hook:
    `data-testid="lms-validate-error"`.
  - Screen reader: `role="alert"` with `aria-atomic="true"`.
- [x] **Validation result panel** (region, conditional)
  - Function: shows the structured verdict when `result` is set. Carries
    `data-testid="lms-validate-result"` and
    `data-lms-validate-valid="{true| false}"`; border `L.accent` when valid,
    else `L.alert`. Heading is "Descriptor is valid ✓" or "Descriptor has {N}
    issue{s}". When issues exist, a `<ul>` lists each as
    `[{severity}] {path}: {message} ({code})`.
  - Screen reader: `role="status"` with `aria-live="polite"` and
    `aria-atomic="true"`.

### Footer action row (page-level)

- [x] **← Operator hub** (link, `LBtn kind="ghost" size="md" href="/operator"`)
  - Function: navigates to `/operator`.
  - Keyboard: focusable anchor; Enter activates.
  - Screen reader: announces "← Operator hub".
  - Touch target: `minHeight: 44`; covered by the route spec.
  - Disabled when: never.
  - Telemetry: none in source.
- [x] **SSO** (link, `LBtn kind="ghost" size="md" href="/operator/sso"`)
  - Function: navigates to `/operator/sso`.
  - Screen reader: announces "SSO".
  - Telemetry: none in source.
- [x] **Tenants** (link,
      `LBtn kind="primary" size="md"     href="/operator/tenants"`)
  - Function: navigates to `/operator/tenants`.
  - Screen reader: announces "Tenants".
  - Telemetry: none in source.

### Non-interactive elements (documented for completeness)

- **Invalid-connector panel** — presentational; no controls. Per-issue text is
  rendered verbatim from the BFF (`[{severity}] {path}: {message} ({code})`).
- **Connector roster cards** — presentational `<div>`s; not links, no buttons.
- **Masthead** — presentational (`left`/`right`/`kicker`/`title`).

## Data & contracts

- **Reads**: `GET /v1/admin/lms/connectors` via `bffGet` (server) →
  `{ generatedAt?, connectors: ConnectorSummary[], invalid: InvalidConnector[] }`.
  `ConnectorSummary = { connectorId, tenantId, source?: 'env' | 'managed', providerKey, displayName, status, environment, capabilities[], lti13: { issuer, clientId, deploymentIds[] } | null, hasScorm }`.
  `InvalidConnector = { connectorId, tenantId | null, issues: { code, path, severity, message }[] }`.
  The summary omits secrets.
- **Managed connector writes**: `POST /v1/admin/lms/connectors` via `api.post`
  (client island) validates and registers a descriptor in the admin-managed
  runtime store; `DELETE /v1/admin/lms/connectors/:tenantId/:connectorId`
  removes a managed descriptor, restores an env descriptor hidden by a managed
  override, or tombstones an env-only descriptor for the current runtime.
  Responses return `{ generatedAt, state, connector }`, where state includes
  `created`, `updated`, `overrode_env`, `deleted`, `env_disabled`, or
  `env_restored`.
- **Dry-run validator write**: `POST /v1/admin/lms/connectors/validate` via
  `api.post` (client island) — dry-run only, no persistence; accepts the parsed
  descriptor object, returns `{ valid: boolean, issues: ValidationIssue[] }`.
- **OneRoster preview write**: `POST /v1/admin/lms/oneroster/preview` via
  `api.post` (client island) — dry-run only, no persistence; accepts
  `{ tenantId, incomingUsers, incomingEnrollments }`, diffs through the
  committed OneRoster projection, and returns
  `{ ok: true, preview: { canApply, conflicts, summary, rows[] } }`. `rows[]`
  are user create/update, enrollment create, and conflict-block rows.
- **Realtime**: none.
- **Caching**: server fetch through `bffGet` — `cache: 'no-store'` when an
  access token is forwarded (authenticated), else `next.revalidate` (default 60
  s) for anonymous. `bffGet` returns `null` on timeout (5 s) / non-2xx / network
  error → page renders the empty state.
- **Auth/role check**: route session-gate via `proxy.ts` (path absent from
  `PUBLIC_PATHS`/`PUBLIC_PREFIXES` → protected branch → 307 to `/welcome` when
  unauthenticated). BFF endpoints require `admin:*` or
  `admin:workspace:tenant-console` (generic `admin:studio` is rejected); a
  wrong-scope token returns 403 `admin_scope_missing`, a missing-bearer 401, a
  missing-ids body 400 `invalid_request`.

## E2E coverage

The Playwright route specs are the authoritative real-infra proof for this
walkthrough: they run against the dev Next/BFF stack, verify the operator admin
cookie is forwarded as the BFF bearer, bridge browser CRUD / OneRoster /
validate requests back to the live BFF, prove malformed client payloads do not
POST, and exercise BFF 401/403/400/422/503 contracts and fail-closed launch
behavior. Page and component Vitest coverage backs the same UI contracts at
narrower layers.

- `apps/oshun/web/e2e/operator-lms.spec.ts` — page DOM coverage:
  generatedAt/count/card/field anchors; seeded roster + invalid panel via admin
  access cookie; OneRoster preview table seeded through real
  `/v1/admin/lms/oneroster/apply` and driven through browser
  `/v1/admin/lms/oneroster/preview` with no roster mutation; wrong-scope
  fail-soft empty state; malformed JSON with no POST; real-BFF 400/invalid/valid
  validate legs; browser managed connector save/delete through the real BFF
  bridge; native disabled/busy button semantics; alert/status feedback; footer
  destinations/tap targets; and 503 failure copy.
- `apps/oshun/web/e2e/lms-connectors.spec.ts` — BFF/admin connector coverage:
  seeded valid connector, retained invalid descriptor, auth gates, dry-run
  invalid/valid verdicts, managed create/delete, managed override over env,
  delete-to-restore-env, and tenant-facing LTI login → launch fail-closed when
  the seeded platform JWKS is unavailable.
- `apps/oshun/web/src/app/operator/lms/__tests__/page.test.tsx` —
  server-rendered page structure and empty/fallback rendering.
- `apps/oshun/web/src/components/lilith/OneRosterDiffPreviewForm.test.tsx` and
  `apps/oshun/web/src/components/lilith/LmsConnectorValidateForm.test.tsx` —
  client island guardrails for preview and dry-run validation behavior.

## Cross-references

- Source: `apps/oshun/web/src/app/operator/lms/page.tsx`
- Component sources:
  - `apps/oshun/web/src/components/lilith/LmsConnectorCrudPanel.tsx`
  - `apps/oshun/web/src/components/lilith/LmsConnectorValidateForm.tsx`
  - `apps/oshun/web/src/components/lilith/OneRosterDiffPreviewForm.tsx`
  - `apps/oshun/web/src/design-system/lilith` (`LWebShell`, `LCustomerNav`,
    `LMasthead`, `LBtn`, `LEyebrow`, `LV`/`L`, `SERIF_L`, `MONO_L`)
  - `apps/oshun/web/src/lib/server/bff-fetch.ts` (`bffGet`)
  - `apps/oshun/web/src/lib/api-client.ts` (`api`, `ApiRequestError`)
- Sibling routes: [`operator-sso.md`](./operator-sso.md),
  [`operator-tenants.md`](./operator-tenants.md),
  [`operator-overview.md`](./operator-overview.md)
- Unit: `apps/oshun/web/src/app/operator/lms/__tests__/page.test.tsx` (present
  at this commit),
  `apps/oshun/web/src/components/lilith/OneRosterDiffPreviewForm.test.tsx`

## Known downstream boundaries

- [ ] **No "you lack access" copy for a signed-in non-admin** — a 403 GET
      degrades to the generic empty card ("No LMS connectors configured…"),
      which can mislead an under-scoped operator into thinking nothing is
      configured rather than that they lack `admin:*` /
      `admin:workspace:tenant-console`.
- [ ] **Managed connector storage is not durable yet** — create/override/delete
      is available in the running BFF, but production persistence, audit-event
      taxonomy, and external LMS-side registration ownership remain future
      hardening.
