Context. surface operator · domain system · route /operator/lms · auth signed-in · source apps/oshun/web/src/app/operator/lms/page.tsx
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
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/ssovia the sibling SSO ghost button in the foot row (/operator/lmslinks 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 viaproxy.ts(the path is not inPUBLIC_PATHS/PUBLIC_PREFIXES); the BFF GET itself is admin-gated, so a non-admin session degrades to the empty state via fail-softbffGet. - 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 —
LWebShellwithLCustomerNav active="library"(top slot). - Masthead:
LMasthead—left="Operator · LMS",right="{N} connector{s}"(pluralized offconnectors.length),kicker="Tenant integrations",title= "The LMS roster." (the word "LMS" is italicized via an inner<em>). Its wrapper carriesdata-operator-lms-masthead="",data-operator-lms-connector-count="{connectors.length}", anddata-operator-lms-invalid-count="{invalid.length}". - Invalid-connector panel — rendered FIRST and prominently, only when
invalid.length > 0. Carriesdata-lms-invalid-panel="{count}", andata-operator-lms-invalid-panel="", anddata-operator-lms-invalid-count="{invalid.length}"on anL.alert-bordered card onL.paper. It shows an uppercase mono heading "{N} connector{s} skipped at boot — fix and redeploy", then one block per invalid descriptor withdata-operator-lms-invalid-card="",data-operator-lms-invalid-connector-id,data-operator-lms-invalid-tenant-id(nonewhen absent), anddata-operator-lms-invalid-issue-count. Individual issues carrydata-operator-lms-invalid-issue="",data-operator-lms-invalid-issue-code,data-operator-lms-invalid-issue-path, anddata-operator-lms-invalid-issue-severity, while visible text remains[{severity}] {path}: {message} ({code}). - Connector roster (main) — a
display: gridof cards (marginTop: 36). The wrapper carriesdata-operator-lms-roster=""anddata-operator-lms-roster-count="{connectors.length}". Each card carriesdata-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 remainsL.accentwhenstatus === 'active', elseL.rule. - Empty state — when
connectors.length === 0: a dashed-border card reading "No LMS connectors configured. SetOSHUN_LMS_CONNECTORSor create a managed descriptor below to onboard a tenant platform." and carryingdata-operator-lms-empty="". - Managed connector CRUD —
<LmsConnectorCrudPanel />client island (below the connector roster). It carriesdata-lms-crud-panel="",data-lms-crud-state="{idle|saving|deleting|success|error}", anddata-lms-crud-connector-count="{connectors.length}"; shows a managed/env source count viadata-lms-crud-source-summary; accepts descriptor JSON, saves throughPOST /v1/admin/lms/connectors, deletes throughDELETE /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 carriesdata-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 stabledata-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 carriesdata-operator-lms-footer="".
States#
- 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.) - Empty —
connectors.length === 0→ dashed-border card "No LMS connectors configured. SetOSHUN_LMS_CONNECTORSor 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: [] }). - Populated (short) — 1–5 connectors → roster cards as described; the
masthead
rightshows "{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.
- Error (recoverable) — N/A for the server read:
bffGetreturnsnullon 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. - 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.
- Gated — the route requires a session (
proxy.ts307→/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
LWebShellchrome; 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".
- Managed connector descriptor JSON (textarea)
- Function: holds a full
LmsConnectorDescriptorJSON object.onChangeupdates localdescriptorJsonstate. 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".
- Function: holds a full
- 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 callrouter.refresh(). - Keyboard: activated by Enter/Space when focused.
- Screen reader: announces "Save connector" or "Saving"; carries
aria-disabledandaria-busyduring 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.
- 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
- 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"anddata-testid="lms-crud-delete-connector". - Touch target:
minHeight: 44,minWidth: 180.
- Function: hold the
- 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 callsrouter.refresh(). - Keyboard: activated by Enter/Space when focused.
- Screen reader: announces "Delete" or "Deleting"; carries
aria-disabledandaria-busyduring 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.
- Function: DELETEs
- 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, witharia-live="polite"andaria-atomic="true".
- Function: shows pending, success, or error copy with
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".
- OneRoster preview JSON (textarea)
- Function: holds
{ tenantId, incomingUsers, incomingEnrollments }JSON.onChangeupdates localpayloadJsonstate. 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".
- Function: holds
- 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; carriesaria-disabledandaria-busyduring pending work. - Touch target: native button with
minHeight: 44; covered by the route spec. - Disabled when:
busyis true ORpayloadJson.trim()is empty — nativedisabledandaria-disabledare set. - Offline behavior: the POST rejects → generic "Could not preview the OneRoster diff - the service is unavailable." inline error.
- Telemetry: none in source.
- 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
- Diff error notice (text, conditional)
- Function: shows the current
errorstring inL.alertviaLEyebrow. Possible strings: invalid JSON, missing required preview fields (400), missing admin scope (403), and unavailable service (other/network). - Screen reader:
role="alert"witharia-atomic="true". - Test hook:
data-testid="oneroster-diff-error".
- Function: shows the current
- Diff result table (region/table, conditional)
- Function: renders
{ preview }from the real BFF. The panel carriesdata-testid="oneroster-diff-result",data-oneroster-diff-can-apply="{true|false}", anddata-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 carriesdata-oneroster-diff-row="",data-oneroster-diff-row-kind,data-oneroster-diff-row-action, anddata-oneroster-diff-row-sourced-id. - Clean preview rows render
create/updateuser changes and enrollment creates; blocked previews renderconflictrows with actionblock. - Screen reader: result wrapper uses
role="status",aria-live="polite", andaria-atomic="true".
- Function: renders
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;
onChangeupdates localdescriptorJsonstate.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".
- Function: holds the candidate descriptor JSON the operator pastes;
- 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/validateviaapi.postand 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; carriesaria-disabledandaria-busyduring pending work. - Touch target: native button with
minHeight: 44; covered by the route spec. - Mobile (≤ 640 px): no delta in source.
- Disabled when:
busyis true ORdescriptorJson.trim()is empty — nativedisabledandaria-disabledare 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.
- 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
- Validation error notice (text, conditional)
- Function: shows the current
errorstring inL.alertviaLEyebrow. 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"witharia-atomic="true".
- Function: shows the current
- Validation result panel (region, conditional)
- Function: shows the structured verdict when
resultis set. Carriesdata-testid="lms-validate-result"anddata-lms-validate-valid="{true| false}"; borderL.accentwhen valid, elseL.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"witharia-live="polite"andaria-atomic="true".
- Function: shows the structured verdict when
Footer action row (page-level)#
- ← 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.
- Function: navigates to
- SSO (link,
LBtn kind="ghost" size="md" href="/operator/sso")- Function: navigates to
/operator/sso. - Screen reader: announces "SSO".
- Telemetry: none in source.
- Function: navigates to
- Tenants (link,
LBtn kind="primary" size="md" href="/operator/tenants")- Function: navigates to
/operator/tenants. - Screen reader: announces "Tenants".
- Telemetry: none in source.
- Function: navigates to
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/connectorsviabffGet(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/connectorsviaapi.post(client island) validates and registers a descriptor in the admin-managed runtime store;DELETE /v1/admin/lms/connectors/:tenantId/:connectorIdremoves 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 includescreated,updated,overrode_env,deleted,env_disabled, orenv_restored. - Dry-run validator write:
POST /v1/admin/lms/connectors/validateviaapi.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/previewviaapi.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), elsenext.revalidate(default 60 s) for anonymous.bffGetreturnsnullon timeout (5 s) / non-2xx / network error → page renders the empty state. - Auth/role check: route session-gate via
proxy.ts(path absent fromPUBLIC_PATHS/PUBLIC_PREFIXES→ protected branch → 307 to/welcomewhen unauthenticated). BFF endpoints requireadmin:*oradmin:workspace:tenant-console(genericadmin:studiois rejected); a wrong-scope token returns 403admin_scope_missing, a missing-bearer 401, a missing-ids body 400invalid_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/applyand driven through browser/v1/admin/lms/oneroster/previewwith 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.tsxandapps/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.tsxapps/oshun/web/src/components/lilith/LmsConnectorValidateForm.tsxapps/oshun/web/src/components/lilith/OneRosterDiffPreviewForm.tsxapps/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-tenants.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.