Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 2 additions & 12 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 11 additions & 11 deletions docs/TENANCY.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,10 @@ same way:
`"owners"` (default), `"owners-admins"`, or `"none"`.

`@workbench/access-policy`'s `POST /api/tenants/:tenantId/access-policy/
child-tenants` is the polished UI-facing wrapper `@corbits/bench-ui`'s
`createBench` calls whenever a `parentId` is given — it makes the same
decision and gives a clean pre-flight 403, but the guard above is what
actually closes the gap; the wrapper alone would not.
child-tenants` is the polished UI-facing wrapper for creating a child
tenant under a parent — it makes the same decision and gives a clean
pre-flight 403, but the guard above is what actually closes the gap; the
wrapper alone would not.

Interchange currently does **not** validate `parentId` on POST and has
**no** cycle constraint — see gaps below.
Expand Down Expand Up @@ -208,13 +208,13 @@ Creation helper: `createDmWorkbenchSpec` in `@corbits/bench-ui`.
dual-membership path) — that path is unaffected by, and independent
of, the projection machinery above.

### Tenancy kind (bench switcher)
### Tenancy kind (bench vs workbench child)

A native tenant row carries no `kind`/`type` field, so `/api/me/principals`
returns one row per tenant a principal belongs to — workbenches and
workbench child tenancies alike, indistinguishable to the platform. The
bench switcher needs to show only real workbenches, so workbench owns
the discriminator:
web client needs to treat only real workbenches as selectable benches, so
workbench owns the discriminator:

- `packages/chat`'s `workbench_tenancy` link table is the source of truth
for "this tenant is a workbench". `WorkbenchTenancyStore.listWorkbenchTenantIds`
Expand All @@ -223,9 +223,9 @@ the discriminator:
the web client for the caller's own tenant ids.
- `@corbits/bench-ui`'s `classifyBenchMembership` combines that set with
`isRawIdentifier` (a tenant with no human-assigned name never renders,
regardless of kind) to produce a `TenancyKind`: `"workbench"`,
`"workbench"`, or `"unknown"`. `filterWorkbenchMemberships` is what the
switcher renders from.
regardless of kind) to produce a `TenancyKind`: `"bench"`,
`"workbench"`, or `"unknown"`. `filterBenchMemberships` is what callers
use to keep only real benches.

This is the extension point for every other tenancy kind the product
adds (sub-workbenches, DMs, shared workbenches): each is still a tenant
Expand Down Expand Up @@ -303,7 +303,7 @@ needs a weaker role, that is an Interchange conversation first.

## Related packages

- `@corbits/bench-ui` — switcher, create dialog, members, tenancy contracts
- `@corbits/bench-ui` — tenancy-kind helpers, workbench-tenancy client, tenancy contracts
- `@workbench/onboarding` — personal bench provision under operator parent
- `@workbench/access-policy` — closed-by-default signup/sub-workbench-
creation policy, pending invites (CL-5886)
Expand Down
27 changes: 8 additions & 19 deletions packages/bench-ui/README.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,22 @@
# @corbits/bench-ui

Bench (tenant) management UI: the bench switcher, bench creation, and
member/invite management, built over Interchange's native tenancy routes.
Presentational primitives (buttons, dialogs, listboxes) come from
`@corbits/react-ui`
([corbitsdev/react-ui](https://github.com/corbitsdev/react-ui)); this
package holds the workbench-specific composition and the tenancy HTTP
client on top of them.
Bench tenancy helpers over Interchange's native tenant model: classifying
memberships by kind, asking which tenant ids are workbench child tenancies,
and the shared tenancy contracts (roles, signup mode, DM naming, parent
cycle checks). Creation lives at `/new`; people management lives in
`@corbits/settings-ui`'s PeopleSection — this package no longer ships
switcher / create / members UI.

## Key modules

- `bench-switcher.tsx` — the sidebar's bench dock: trigger plus popover to
switch benches or create a new one
- `create-bench-dialog.tsx` — the new-bench flow
- `member-list.tsx` / `members-panel.tsx` / `invite-member-dialog.tsx` —
viewing, inviting, and managing bench membership
- `membership.ts` — pure helpers: slug derivation, membership display,
role labels
- `tenancy-kind.ts` — classifying a membership by tenancy kind
- `api.ts` — `listWorkbenchTenantIds` HTTP client
- `membership.ts` — `isRawIdentifier` (raw platform ids must never render)
- `tenancy-contracts.ts` — shared tenancy constants and validation (roles,
signup mode, DM workbench naming, parent-tenant cycle checks)
- `api.ts` — the bench HTTP client: memberships, creation, members,
settings

## Running tests

```
cd packages/bench-ui && bun test
```

Suites render with `react-dom/server`'s `renderToStaticMarkup` rather than
a mounted DOM, so no `bunfig.toml` preload is needed here.
2 changes: 0 additions & 2 deletions packages/bench-ui/bunfig.toml

This file was deleted.

12 changes: 2 additions & 10 deletions packages/bench-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@corbits/bench-ui",
"private": true,
"description": "Bench (tenant) management UI: the switcher, the benches page, and member/invite management, composed from the published component library over Interchange's native tenancy routes",
"description": "Bench tenancy helpers: membership kind classification, workbench-tenancy client, and shared tenancy contracts over Interchange's native tenant model",
"version": "0.0.1",
"license": "LGPL-2.1-or-later",
"type": "module",
Expand All @@ -15,19 +15,11 @@
},
"dependencies": {
"@corbits/api-query": "workspace:*",
"@corbits/bench": "workspace:*",
"@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635",
"@intx/types": "0.3.0",
"arktype": "catalog:",
"@corbits/icons": "workspace:*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"arktype": "catalog:"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.11.2",
"@types/bun": "catalog:",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"typescript": "catalog:"
}
}
93 changes: 6 additions & 87 deletions packages/bench-ui/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,15 @@
// The bench surface's one seam to Interchange's native tenancy routes
// (see vendor/intx/hub-api/src/routes/tenants.ts and principals.ts). Every
// fetch the bench/* components make goes through a function here, and every
// response is parsed with an arktype schema from `@intx/types` — the one
// real wire contract, never a hand-copied second one — at the boundary.
// The one HTTP seam this package still owns: asking which of the caller's
// tenant ids are workbench child tenancies. Member/invite/create clients
// lived here while the dead switcher and MembersPanel did; those UIs are
// gone (creation is `/new`, people management is settings-ui's PeopleSection),
// so the orphan invite/create/list clients went with them.

import { type } from "arktype";
import type { ArkErrors } from "arktype";
import {
PrincipalResponse,
PrincipalSummary,
TenantResponse,
paginatedSchema,
} from "@intx/types";
import { PrincipalSummary } from "@intx/types";
import { UnauthenticatedError } from "@corbits/api-query";
import { getBenchSettings, patchBenchSettings } from "@corbits/bench/client";
import type {
BenchSettingsPatch,
BenchSettingsResponse,
} from "@corbits/bench/client";

// Purpose and type aren't part of Interchange's native tenant shape (see
// this file's header note), so they come from `@corbits/bench`'s own
// side-table client — re-exported here rather than imported directly by
// components, so `bench-ui`'s components keep this one seam.
export { getBenchSettings, patchBenchSettings };
export type { BenchSettingsPatch, BenchSettingsResponse };

export type BenchMembership = typeof PrincipalSummary.infer;
export type BenchMember = typeof PrincipalResponse.infer;
export type Bench = typeof TenantResponse.infer;

const MembershipsPage = paginatedSchema(PrincipalSummary);
const MembersPage = paginatedSchema(PrincipalResponse);

export class BenchApiError extends Error {
constructor(
Expand Down Expand Up @@ -79,65 +57,6 @@ async function request<T>(
return parsed;
}

/** The caller's own memberships, one row per bench they belong to. Only the
* first page — a person on more than a page of benches is not a case this
* surface handles yet, matching the same simplification `apps/web`'s
* settings page already makes over this same endpoint. */
export function listMyMemberships(): Promise<readonly BenchMembership[]> {
return request("/api/me/principals", MembershipsPage).then(
(page) => page.data,
);
}

export type CreateBenchInput = {
readonly name: string;
readonly slug: string;
readonly parentId?: string;
};

/**
* Creates a bench. A `parentId` (creating a sub-workbench under an
* existing one) routes through `@workbench/access-policy`'s gated
* surface instead of the native route directly — that surface checks
* the parent's own `tenancyCreation` policy against the caller's roles
* before ever calling `POST /api/tenants` itself. A bare top-level
* bench (no `parentId`) is unaffected and still hits the native route.
*/
export function createBench(input: CreateBenchInput): Promise<Bench> {
if (input.parentId !== undefined) {
return request(
`/api/tenants/${input.parentId}/access-policy/child-tenants`,
TenantResponse,
{
method: "POST",
body: JSON.stringify({ name: input.name, slug: input.slug }),
},
);
}
return request("/api/tenants", TenantResponse, {
method: "POST",
body: JSON.stringify(input),
});
}

/** Every member of one bench. Only the first page, same simplification as
* `listMyMemberships`. */
export function listMembers(tenantId: string): Promise<readonly BenchMember[]> {
return request(`/api/tenants/${tenantId}/principals`, MembersPage).then(
(page) => page.data,
);
}

export function inviteMember(
tenantId: string,
email: string,
): Promise<BenchMember> {
return request(`/api/tenants/${tenantId}/members/invite`, PrincipalResponse, {
method: "POST",
body: JSON.stringify({ email }),
});
}

const WorkbenchTenantIds = type({
workbenchTenantIds: "string[]",
});
Expand Down
Loading
Loading