Context. surface operator · domain operator (tenant — institution-scoped admin) · route /operator/tenant · auth signed-in · source apps/oshun/web/src/app/operator/tenant/page.tsx
Last walked. 2026-07-02 tenant-bulk-export addendum — /operator/tenant now includes a live bulk-export release panel (TenantBulkExportPanel) for admin-scoped content JSON export creation, manifest/file-hash inspection, integrity verification, release approval, and raw released-file download through the real BFF. Evidence: WALKTHROUGH/results/tenant-bulk-operations-export.md; spec: apps/oshun/web/e2e/tenant-bulk-operations-export-ui.spec.ts. 2026-06-30 tenant-member-register addendum — Invite and + Add member now refresh the visible members table through the live tenant-console cockpit read after the real BFF write. The TenantMemberRegister client island preserves the 8 fixture rows, appends live BFF member/invite rows with data-tenant-member-row-source="live", and keeps Export CSV bound to the current rendered roster. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §243; spec: apps/oshun/web/e2e/tenant-member-actions.spec.ts. 2026-06-29 tenant-integrations addendum — /operator/tenant now includes the live integrations registry panel (TenantIntegrationsPanel) for admin-scoped API-key creation, webhook subscription creation, and signed sandbox simulator checks against the real BFF. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §221; spec: apps/oshun/web/e2e/tenant-integrations-ui.spec.ts. 2026-06-25 tenant-export addendum — Export CSV is now a real client-side download generated from the rendered TenantConsoleData member rows (TenantMemberExportCsv), with Playwright verifying filename, row count, and CSV content. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §99; specs: apps/oshun/web/e2e/tenant-onboard.spec.ts and apps/oshun/web/e2e/tenant-member-invite-scim.spec.ts. 2026-06-25 operator-surfaces tenant-console row-cell addendum — Playwright real-dev-infra coverage for signed-in /operator/tenant render, anonymous proxy redirect, KPI grid fixture values, all 8 member rows, every row cell (id/member/plan/status/sittings/last-seen/action placeholder), row non-focusability, plan/status diversity, Export CSV control, Invite dialog focus, and + Add member dialog focus. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §86; spec: apps/oshun/web/e2e/operator-surfaces.spec.ts.
Purpose#
Institution-scoped admin: members, plans, sittings, status, last-seen, plus the
operator-facing integrations registry for API keys, webhook subscriptions, and
sandbox signed-delivery checks, and bulk-export release controls for manifest
verification / approval / download. Robots-noindex. The "Lilith Console"
register-style surface for managing a single tenant's membership, activity, and
integration edge. Backed by getTenantConsole() for the initial member fixture,
live BFF calls for the integrations and bulk-export panels, and a live
tenant-console cockpit read after Invite / + Add member writes, rendered by
TenantConsole and TenantMemberRegister.
Entry points#
- From
/operator(index) — operator-surface card - Profile menu → Operator → Tenant
- Direct URL — session-gated only (
proxy.tsredirects sessionless →/welcome?redirect=); no operator-role gate in the proxy; tenant context resolved server-side - From
/operator/admininbox detail — tenant-class INC links here - Notification handoff — tenant requests / approvals route here
Layout regions#
TenantConsole uses Lilith primitives (LGraphPaper, LOperatorBar,
LEyebrow, LCode, LChip, LDataCell, L, SERIF_L, SANS_L).
- Top:
LOperatorBar— product="Lilith Console", section="Tenant", breadcrumb="Pacific Theological › Members" (separator isCRUMB_SEP = ' › ', operator.tsx:38) - Header band (flex space-between):
- Left: eyebrow accent "Members · Pacific Theological Seminary"; Garamond 28 line: "247 members · 192 active this week" (italic count + muted suffix)
- Right: action row inside
TenantMemberRegister—TenantMemberExportCsvbutton "Export CSV",LBtn"Invite",LBtn"+ Add member"; CSV binds to the current rendered roster, including any live rows appended after a successful BFF write/readback
- KPI grid — 5-column, inline-borders (no border on first cell); Garamond 36 italic value + eyebrow label
- Integrations registry panel —
TenantIntegrationsPanelbelow the KPI grid; loadsGET /v1/admin/integrations/snapshotfrom the browser using the active admin bearer; displays API-key/webhook/delivery counts, recent redacted key/webhook/delivery rows, and compact controls for sandbox tenant, API-key name/scopes, webhook name/topic/target URL, and simulator topic - Bulk export release panel —
TenantBulkExportPanelbelow the integrations registry; loadsGET /v1/admin/bulk-exportsfrom the browser using the active admin bearer; creates a content JSON export, shows manifest and file hashes, verifies integrity, approves release with a rationale, and downloads the released raw file - Members table:
- Header row:
ID / Member / Plan / Status / Sittings / Last seen /(final 60-px column for actions); grid template110px 2fr 1fr 1fr 1fr 1fr 60px; uppercase eyebrows - Body rows:
<div data-tenant-member-row>using the same template; stable attributes exposedata-tenant-member-row-source(fixture/live),data-tenant-member-id,data-tenant-member-plan, anddata-tenant-member-status. Cell spans exposedata-tenant-member-cellvaluesid,member,plan,status,sittings,last-seen, andactions; the action cell is a visible ellipsis placeholder only
- Header row:
- Full padding
24px 28px
States#
- Loading — no route
loading.tsx;getTenantConsole()is a server-side static fixture and the page blocks until it renders - Anonymous user — redirected to
/welcome?redirect=byproxy.ts - Signed-in user — renders (session-gated only; no operator-role gate in the proxy)
- Tenant present + populated — header counts visible; 8 table rows render
- Empty tenant (no members) — table empty state (verify)
- Plan diversity — Solo, Hearth, and Institutional render distinctly
- Status diversity — active / lapsed / trial / paused render distinctly in the current fixture; invited is covered by the live invite refresh path; suspended / deactivated are not present in the shipped fixture
- Long member list — verify pagination, scroll, or virtualization
-
getTenantConsole()fails — no routeerror.tsxexists underapps/oshun/web/src/app/operator; a render crash falls through to the nearest parent/global boundary - Realtime presence — "Last seen" updates if a member comes online during the session (verify)
- Integrations snapshot loading — the panel waits for authenticated browser auth, then loads the live BFF snapshot and displays redacted counts/rows
- Integrations action errors — missing/insufficient admin scope renders an alert instead of silently failing
- Bulk export archive loading — the panel waits for authenticated browser auth, then loads the live BFF export list and displays counts, release state, and recent archive rows
- Bulk export action errors — missing/insufficient admin scope renders an alert; release/download actions remain placeholder-disabled until an inspected archive is present and approved
Interactions#
Header band#
- Eyebrow + heading — non-interactive labels; Playwright asserts the tenant eyebrow, count heading, and action row
- "Export CSV" — client-side download generated from the same
TenantConsoleData.rowsrendered in the table; Playwright verifiespacific-theological-members.csv, six canonical columns, and the first / last fixture rows -
LBtn"Invite" — opens the invite dialog and focuses the email field; successful submit writes to the real BFF, refreshesGET /v1/tenant-console/members, and appends a liveinvitedrow for the pending invite -
LBtn"+ Add member" — opens the add-member dialog and focuses the user-id field; successful submit writes to the real BFF, refreshesGET /v1/tenant-console/members, and appends a liveactivemember row
KPI grid#
- Each KPI cell — non-interactive numeric display; 5 cells verified
Integrations registry#
- Snapshot refresh —
Refreshrereads the live integrations snapshot through the browser API client with the active bearer - Create key — submits the typed tenant ID, display name, and scope list
to
POST /v1/admin/integrations/api-keys; the one-time raw token is shown in the result block while the snapshot read-back remains redacted - Create webhook — submits the typed tenant ID, display name, topic
pattern, and target URL to
POST /v1/admin/integrations/webhooks; the one-time signing secret is shown in the result block while the snapshot read-back remains redacted - Simulate webhook — posts the selected subscription, sandbox tenant,
topic, and operator-console source payload to
POST /v1/admin/integrations/webhooks/simulator; the panel displays the live delivery ID, status,matched/signature-verification attributes, and returnedx-oshun-signature
Bulk export release#
- Refresh — rereads
GET /v1/admin/bulk-exportswith the active bearer - Create export — posts a browser-entered operation ID, tenant ID, and
two content rows to
POST /v1/admin/bulk-exports; the panel then reads/v1/admin/bulk-exports/:opIdand displays record count, manifest hash, file name, content hash, and pending release state - Verify manifest — posts
POST /v1/admin/bulk-exports/:opId/verify; the panel displaysManifest verified.when the BFF recomputes the archive successfully - Approve release — posts
POST /v1/admin/bulk-exports/:opId/release-approvalwith a compliance rationale; the panel updates release state toapprovedand exposes the approver user ID - Download released file — disabled until release is approved; then
reads
GET /v1/admin/bulk-exports/:opId/files/:fileNameas raw text and shows the downloaded content in the result block. Playwright asserts the response hash/residency headers and structured audit events.
Members table#
- Header row — non-interactive column labels verified by page-DOM
- Each body row:
LCodeID — non-interactive; ID anchors verified for fixture rows- Member name — name renders; no email or member-detail link is wired today
- Plan — non-interactive; Solo / Hearth / Institutional verified
- Status — pill; active / lapsed / trial / paused verified
- Sittings — non-interactive count
- Last seen — non-interactive timestamp
- Action cell (60 px) — placeholder ellipsis only; no kebab menu opens today.
Playwright asserts all eight fixture rows cell-by-cell and verifies no row
contains a link/button/
tabindexfocus target
- Live mutation rows — after Invite or + Add member succeeds, the client
reads the tenant-console cockpit and appends live BFF rows below the
fixture register. Playwright asserts row provenance (
live), status, member, plan/role, sitting, and last-seen cells for both paths. - Keyboard — Playwright asserts the body rows are not keyboard-focusable links or menus today; the only keyboard-driven member actions are the header Invite / + Add member buttons. Row Enter activation is therefore a missing product affordance, not a hidden behavior
- SR — table semantics; row announces member context
Data & contracts#
- Reads:
- Initial console shell —
getTenantConsole()from@/lib/lilith-data/operator-depthreturningTenantConsoleData(kpi, fixture members table data, tenant context). NOTE: this is a static in-process fixture (returnsgetTenantConsoleFixture()directly), not a BFF call — the module is headed "fixture today, BFF tomorrow" - Post-mutation member refresh —
GET /v1/tenant-console/members?tenantId=tenant_oxbowviaTenantMemberRegister, using tenant-admin scope to append live BFF member/invite rows after Invite and + Add member writes
- Initial console shell —
- Writes:
- Invite member —
POST /v1/admin/tenant-console/members/inviteviaTenantMemberActions - Add member —
POST /v1/admin/tenant-console/membersviaTenantMemberActions - Export CSV — synchronous browser download via
TenantMemberExportCsvfrom rendered tenant rows; bulk import/dry-run remains a separate unbuilt path - Integrations snapshot —
GET /v1/admin/integrations/snapshotviaTenantIntegrationsPanel - Create integration API key —
POST /v1/admin/integrations/api-keysviaTenantIntegrationsPanel - Create webhook subscription —
POST /v1/admin/integrations/webhooksviaTenantIntegrationsPanel - Run sandbox webhook simulator —
POST /v1/admin/integrations/webhooks/simulatorviaTenantIntegrationsPanel - Bulk exports list/detail —
GET /v1/admin/bulk-exportsandGET /v1/admin/bulk-exports/:opIdviaTenantBulkExportPanel - Create bulk export —
POST /v1/admin/bulk-exportsviaTenantBulkExportPanel - Verify bulk export —
POST /v1/admin/bulk-exports/:opId/verifyviaTenantBulkExportPanel - Approve bulk-export release —
POST /v1/admin/bulk-exports/:opId/release-approvalviaTenantBulkExportPanel - Download released bulk-export file —
GET /v1/admin/bulk-exports/:opId/files/:fileNameviaTenantBulkExportPanel - Member actions (change plan, suspend, remove) — audit-stamped
- Invite member —
- Realtime: presence updates (last seen) via Psyche channel (verify)
- Caching: server-rendered; no SW caching
- Auth/role check: session-gated only (
proxy.tsredirects sessionless →/welcome?redirect=); no operator-role gate in the proxy. Per-tenant context is resolved server-side ingetTenantConsole()
E2E coverage#
apps/oshun/web/e2e/operator-surfaces.spec.ts— signed-in/operator/tenantrender, anonymous redirect-before-render, KPI strip values, 8-row member table, all body row cells (id/member/plan/status/sittings/last-seen/action placeholder), Institutional/lapsed/trial/paused fixture variants, absence of row-level links/buttons/focus targets, Export CSV control state/row count, Invite dialog focus, and + Add member dialog focus.apps/oshun/web/e2e/tenant-onboard.spec.ts— downloads the tenant roster CSV and verifies filename, header columns, row count, and representative first/last rows.apps/oshun/web/e2e/tenant-member-actions.spec.ts— deeper write coverage for the same Invite / + Add member controls against the real BFF, including invite token creation, liveinvitedrow refresh throughGET /v1/tenant-console/members, add-member success, liveactiverow refresh,tenant.member_invite/tenant.member_createaudit read-back throughGET /v1/tenant-console/audit, invite acceptance, wrong-token/reuse guards, invalid email, and seat capacity.apps/oshun/web/e2e/tenant-integrations-ui.spec.ts— admin-scoped browser session for the/operator/tenantintegrations panel, live BFF snapshot load, UI-driven API-key create request/header/body assertions, raw-token non-leakage in snapshot, UI-driven webhook create request/header/body assertions, signing-secret non-leakage in snapshot, and signed sandbox simulator delivery read-back.apps/oshun/web/e2e/tenant-bulk-operations-export-ui.spec.ts— admin-scoped browser session for the/operator/tenantbulk-export panel, live BFF export list load, UI-driven export create request/header/body assertions, manifest/file hash rendering, verify action, release approval request/body assertions, released-file download response header assertions, downloaded content rendering, and structured audit-log read-back.
Cross-references#
- Parent:
operator-overview.md - Sibling operator routes:
- Component source:
apps/oshun/web/src/components/lilith/operator.tsx(line 336:TenantConsole) - The dedicated tenant admin app at
apps/oshun/tenant-admin/is a separate product — this operator-side console is the platform-operator view into tenants, not the tenant's own admin - Feature spec:
V1/features.md - Architecture:
V1/ARCHITECTURE.md
Open questions / known gaps#
- Build/document the per-row kebab / context menu action set; current route ships a non-interactive ellipsis placeholder only
- Confirm whether "Export CSV" generates synchronously or routes to an async
job — current route generates a synchronous browser CSV from the rendered
roster rows; bulk-export release/download is now live in
TenantBulkExportPanel; CSV import and resumable upload remain separate unbuilt paths - Document the Invite + Add member flows: the header buttons open dialogs;
deeper real-BFF write and live member-row refresh coverage lives in
tenant-member-actions.spec.ts - Verify how a platform operator switches between tenants — there is no
tenant switcher visible in the fixture (page reads tenant context from
getTenantConsole()); is the switcher inoperator/operator-overview.mdor elsewhere? - Clarify the relationship between
/operator/tenant(this surface) andapps/oshun/tenant-admin/*(a separate app) — which audiences see which? - An operator-role gate appears absent —
proxy.tsonly redirects sessionless users to/welcome; no role check in the proxy or page component (possible gap vs intended scope)