---
path: /operator/tenant
surface: operator
domain: operator (tenant — institution-scoped admin)
auth: signed-in
source: apps/oshun/web/src/app/operator/tenant/page.tsx
status: walked
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.
---

# Operator · Tenant console · members

## 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.ts` redirects sessionless →
  `/welcome?redirect=`); no operator-role gate in the proxy; tenant context
  resolved server-side
- **From `/operator/admin`** inbox 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 is
  `CRUMB_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` — `TenantMemberExportCsv`
    button "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** — `TenantIntegrationsPanel` below the KPI
  grid; loads `GET /v1/admin/integrations/snapshot` from 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** — `TenantBulkExportPanel` below the integrations
  registry; loads `GET /v1/admin/bulk-exports` from 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 template `110px 2fr 1fr 1fr 1fr 1fr 60px`;
    uppercase eyebrows
  - Body rows: `<div data-tenant-member-row>` using the same template; stable
    attributes expose `data-tenant-member-row-source` (`fixture` / `live`),
    `data-tenant-member-id`, `data-tenant-member-plan`, and
    `data-tenant-member-status`. Cell spans expose `data-tenant-member-cell`
    values `id`, `member`, `plan`, `status`, `sittings`, `last-seen`, and
    `actions`; the action cell is a visible ellipsis placeholder only
- Full padding `24px 28px`

## States

- [x] **Loading** — no route `loading.tsx`; `getTenantConsole()` is a
      server-side static fixture and the page blocks until it renders
- [x] **Anonymous user** — redirected to `/welcome?redirect=` by `proxy.ts`
- [x] **Signed-in user** — renders (session-gated only; no operator-role gate in
      the proxy)
- [x] **Tenant present + populated** — header counts visible; 8 table rows
      render
- [ ] **Empty tenant** (no members) — table empty state (verify)
- [x] **Plan diversity** — Solo, Hearth, and Institutional render distinctly
- [x] **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 route `error.tsx` exists under
      `apps/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)
- [x] **Integrations snapshot loading** — the panel waits for authenticated
      browser auth, then loads the live BFF snapshot and displays redacted
      counts/rows
- [x] **Integrations action errors** — missing/insufficient admin scope renders
      an alert instead of silently failing
- [x] **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
- [x] **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

- [x] **Eyebrow + heading** — non-interactive labels; Playwright asserts the
      tenant eyebrow, count heading, and action row
- [x] **"Export CSV"** — client-side download generated from the same
      `TenantConsoleData.rows` rendered in the table; Playwright verifies
      `pacific-theological-members.csv`, six canonical columns, and the first /
      last fixture rows
- [x] **`LBtn` "Invite"** — opens the invite dialog and focuses the email field;
      successful submit writes to the real BFF, refreshes
      `GET /v1/tenant-console/members`, and appends a live `invited` row for the
      pending invite
- [x] **`LBtn` "+ Add member"** — opens the add-member dialog and focuses the
      user-id field; successful submit writes to the real BFF, refreshes
      `GET /v1/tenant-console/members`, and appends a live `active` member row

### KPI grid

- [x] **Each KPI cell** — non-interactive numeric display; 5 cells verified

### Integrations registry

- [x] **Snapshot refresh** — `Refresh` rereads the live integrations snapshot
      through the browser API client with the active bearer
- [x] **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
- [x] **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
- [x] **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
      returned `x-oshun-signature`

### Bulk export release

- [x] **Refresh** — rereads `GET /v1/admin/bulk-exports` with the active bearer
- [x] **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/:opId` and displays record count, manifest hash,
      file name, content hash, and pending release state
- [x] **Verify manifest** — posts `POST /v1/admin/bulk-exports/:opId/verify`;
      the panel displays `Manifest verified.` when the BFF recomputes the
      archive successfully
- [x] **Approve release** — posts
      `POST /v1/admin/bulk-exports/:opId/release-approval` with a compliance
      rationale; the panel updates release state to `approved` and exposes the
      approver user ID
- [x] **Download released file** — disabled until release is approved; then
      reads `GET /v1/admin/bulk-exports/:opId/files/:fileName` as raw text and
      shows the downloaded content in the result block. Playwright asserts the
      response hash/residency headers and structured audit events.

### Members table

- [x] **Header row** — non-interactive column labels verified by page-DOM
- [x] **Each body row**:
  - `LCode` ID — 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/`tabindex` focus target
- [x] **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.
- [x] **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-depth` returning `TenantConsoleData` (kpi,
    fixture members table data, tenant context). NOTE: this is a static
    in-process fixture (returns `getTenantConsoleFixture()` directly), **not** a
    BFF call — the module is headed "fixture today, BFF tomorrow"
  - Post-mutation member refresh —
    `GET /v1/tenant-console/members?tenantId=tenant_oxbow` via
    `TenantMemberRegister`, using tenant-admin scope to append live BFF
    member/invite rows after Invite and + Add member writes
- **Writes**:
  - Invite member — `POST /v1/admin/tenant-console/members/invite` via
    `TenantMemberActions`
  - Add member — `POST /v1/admin/tenant-console/members` via
    `TenantMemberActions`
  - Export CSV — synchronous browser download via `TenantMemberExportCsv` from
    rendered tenant rows; bulk import/dry-run remains a separate unbuilt path
  - Integrations snapshot — `GET /v1/admin/integrations/snapshot` via
    `TenantIntegrationsPanel`
  - Create integration API key — `POST /v1/admin/integrations/api-keys` via
    `TenantIntegrationsPanel`
  - Create webhook subscription — `POST /v1/admin/integrations/webhooks` via
    `TenantIntegrationsPanel`
  - Run sandbox webhook simulator —
    `POST /v1/admin/integrations/webhooks/simulator` via
    `TenantIntegrationsPanel`
  - Bulk exports list/detail — `GET /v1/admin/bulk-exports` and
    `GET /v1/admin/bulk-exports/:opId` via `TenantBulkExportPanel`
  - Create bulk export — `POST /v1/admin/bulk-exports` via
    `TenantBulkExportPanel`
  - Verify bulk export — `POST /v1/admin/bulk-exports/:opId/verify` via
    `TenantBulkExportPanel`
  - Approve bulk-export release —
    `POST /v1/admin/bulk-exports/:opId/release-approval` via
    `TenantBulkExportPanel`
  - Download released bulk-export file —
    `GET /v1/admin/bulk-exports/:opId/files/:fileName` via
    `TenantBulkExportPanel`
  - Member actions (change plan, suspend, remove) — audit-stamped
- **Realtime**: presence updates (last seen) via Psyche channel (verify)
- **Caching**: server-rendered; no SW caching
- **Auth/role check**: session-gated only (`proxy.ts` redirects sessionless →
  `/welcome?redirect=`); no operator-role gate in the proxy. Per-tenant context
  is resolved server-side in `getTenantConsole()`

## E2E coverage

- [`apps/oshun/web/e2e/operator-surfaces.spec.ts`](../../apps/oshun/web/e2e/operator-surfaces.spec.ts)
  — signed-in `/operator/tenant` render, 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`](../../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`](../../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, live `invited` row refresh
  through `GET /v1/tenant-console/members`, add-member success, live `active`
  row refresh, `tenant.member_invite` / `tenant.member_create` audit read-back
  through `GET /v1/tenant-console/audit`, invite acceptance, wrong-token/reuse
  guards, invalid email, and seat capacity.
- [`apps/oshun/web/e2e/tenant-integrations-ui.spec.ts`](../../apps/oshun/web/e2e/tenant-integrations-ui.spec.ts)
  — admin-scoped browser session for the `/operator/tenant` integrations 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`](../../apps/oshun/web/e2e/tenant-bulk-operations-export-ui.spec.ts)
  — admin-scoped browser session for the `/operator/tenant` bulk-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`](./operator-overview.md)
- Sibling operator routes:
  - [`operator-admin.md`](./operator-admin.md)
  - [`operator-studio.md`](./operator-studio.md)
  - [`operator-metis.md`](./operator-metis.md)
- 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`](../../V1/features.md#product-surfaces)
- Architecture:
  [`V1/ARCHITECTURE.md`](../../V1/ARCHITECTURE.md#operator-surfaces)

## Open questions / known gaps

- [ ] Build/document the per-row kebab / context menu action set; current route
      ships a non-interactive ellipsis placeholder only
- [x] 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
- [x] 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 in `operator/operator-overview.md`
      or elsewhere?
- [ ] Clarify the relationship between `/operator/tenant` (this surface) and
      `apps/oshun/tenant-admin/*` (a separate app) — which audiences see which?
- [ ] An operator-role gate appears absent — `proxy.ts` only redirects
      sessionless users to `/welcome`; no role check in the proxy or page
      component (possible gap vs intended scope)
