From e9da2097bf17f1439d136020acc9dbafebd42030 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 09:44:06 -0700 Subject: [PATCH 1/2] fix(web): resolve invite roleId via the stock roles route (CL-8131) The stock member invite route only attaches a role when the caller passes roleId; without it an invited principal lands with zero grants and every subsequent read 403s. inviteMember now resolves the manifest's role name to the tenant's own role id over GET /api/tenants/:id/roles before inviting. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- apps/web/src/needs-converge.test.ts | 34 ++++++++++++-- apps/web/src/needs-converge.ts | 71 ++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/apps/web/src/needs-converge.test.ts b/apps/web/src/needs-converge.test.ts index 99ec9b5960..bb34189056 100644 --- a/apps/web/src/needs-converge.test.ts +++ b/apps/web/src/needs-converge.test.ts @@ -115,7 +115,7 @@ describe("group workbench creation sequence", () => { id: "tnt_atlas", name: input.name, slug: input.slug, - parentId: input.parentId, + parentId: input.parentId ?? null, }); }, inviteMember: (tenantId, input) => { @@ -304,7 +304,7 @@ describe("group workbench creation sequence", () => { id: "tnt_atlas", name: input.name, slug: input.slug, - parentId: input.parentId, + parentId: input.parentId ?? null, }), inviteMember: () => Promise.resolve(), deployWorkflow: () => Promise.reject(new Error("unexpected deploy")), @@ -412,7 +412,7 @@ describe("writes after gap checks", () => { id: "tnt_atlas", name: input.name, slug: input.slug, - parentId: input.parentId, + parentId: input.parentId ?? null, }); }; @@ -517,7 +517,7 @@ describe("thread-native DM derivation", () => { id: `tnt_${input.slug}`, name: input.name, slug: input.slug, - parentId: input.parentId, + parentId: input.parentId ?? null, }); }, inviteMember: () => Promise.resolve(), @@ -852,4 +852,30 @@ describe("stock-only fetch hub", () => { expect(JSON.stringify(send?.body)).not.toContain("idempotency"); expect(JSON.stringify(send?.body)).not.toContain("dm:"); }); + + test("inviteMember resolves the role name to the tenant's own role id before inviting", async () => { + const seen: { method: string; url: string; body: unknown }[] = []; + const hub = createFetchStockHub( + stubFetch(seen, { + "/api/tenants/tnt_atlas/roles": { + data: [{ id: "role_member", name: "member" }], + nextCursor: null, + }, + "POST /api/tenants/tnt_atlas/members/invite": {}, + }), + ); + + await hub.inviteMember("tnt_atlas", { + email: "bea@example.com", + role: "member", + }); + + const invite = seen.find((request) => + request.url.startsWith("/api/tenants/tnt_atlas/members/invite"), + ); + expect(invite?.body).toEqual({ + email: "bea@example.com", + roleId: "role_member", + }); + }); }); diff --git a/apps/web/src/needs-converge.ts b/apps/web/src/needs-converge.ts index 3b5f275d42..04eb59b78f 100644 --- a/apps/web/src/needs-converge.ts +++ b/apps/web/src/needs-converge.ts @@ -88,12 +88,26 @@ export type StockHub = { listMyPrincipals(): Promise; getTenant(id: string): Promise; listPrincipals(tenantId: string): Promise; + /** Omitting `parentId` mints a top-level tenant with the caller as its + * owner — the stock route the first-signup installer step uses to + * create the primary tenant; a workbench child tenant always supplies + * it. */ createTenant(input: { name: string; slug: string; - parentId: string; + parentId?: string; }): Promise; - inviteMember(tenantId: string, input: { email: string }): Promise; + /** + * Invites by email and assigns `role` (a system role name, e.g. + * "member") in the same op — the stock invite route's `roleId` is the + * only way an invited principal gets any grant at all; without it the + * principal lands with zero roles and every subsequent read 403s + * (CL-8085/CL-8131 fix). + */ + inviteMember( + tenantId: string, + input: { email: string; role: string }, + ): Promise; deployWorkflow(tenantId: string, input: WorkflowDeployInput): Promise; sendRunMail(input: SendRunMailInput): Promise<{ messageId: string }>; listRunMail(input: { tenantId: string }): Promise; @@ -151,7 +165,11 @@ export class StockHubRequestError extends Error { } } -export async function readHubSnapshot(hub: StockHub): Promise { +/** Every tenant the signed-in user owns (active `owner` role), regardless + * of whether it is a top-level home or a workbench child — the shared + * read behind both the primary-tenant gap check and the first-signup + * installer's "does a primary tenant already exist" probe. */ +export async function findOwnedTenants(hub: StockHub): Promise { const memberships = await hub.listMyPrincipals(); const activeOwned = memberships.filter( (membership) => @@ -159,11 +177,15 @@ export async function readHubSnapshot(hub: StockHub): Promise { membership.status === "active" && membership.roles.some((role) => role.name === "owner"), ); - const tenants = ( + return ( await Promise.all( activeOwned.map((membership) => hub.getTenant(membership.tenantId)), ) ).filter((tenant): tenant is HubTenant => tenant !== null); +} + +export async function readHubSnapshot(hub: StockHub): Promise { + const tenants = await findOwnedTenants(hub); const primaryCandidates = tenants.filter( (tenant) => tenant.parentId === null, ); @@ -244,7 +266,10 @@ async function convergeWorkbenchTenant( }); for (const principal of workbench.principals) { if (principal.email !== undefined) { - await hub.inviteMember(created.id, { email: principal.email }); + await hub.inviteMember(created.id, { + email: principal.email, + role: principal.roles[0] ?? "member", + }); } } return { tenantId: created.id, created: true }; @@ -487,6 +512,39 @@ const MailPageShape = type({ nextCursor: "string | null", }); const SentMailShape = type({ messageId: "string" }); +const RolePageShape = type({ + data: type({ id: "string", name: "string" }).array(), + nextCursor: "string | null", +}); + +/** + * Resolves a system role name (e.g. "member") to its per-tenant role id + * over the stock roles route. A tenant's system roles are seeded by + * `createTenant` itself, so this never needs to page past the first + * roles listing in practice — the route is still cursor-shaped, so this + * follows it rather than assuming a single page. + */ +async function resolveRoleId( + fetchImpl: typeof fetch, + tenantId: string, + roleName: string, +): Promise { + const roles = await fetchAllPages( + fetchImpl, + `/api/tenants/${encodeURIComponent(tenantId)}/roles`, + "listRoles", + (body) => parseBoundary(RolePageShape, body, "listRoles"), + ); + const found = roles.find((role) => role.name === roleName); + if (found === undefined) { + throw new StockHubRequestError( + "listRoles", + undefined, + `Stock roles for workbench ${tenantId} carry no role named "${roleName}".`, + ); + } + return found.id; +} async function readJson( response: Response, @@ -588,13 +646,14 @@ export function createFetchStockHub(fetchImpl: typeof fetch = fetch): StockHub { return { ...parsed, parentId: parsed.parentId ?? null }; }, async inviteMember(tenantId, input) { + const roleId = await resolveRoleId(fetchImpl, tenantId, input.role); await readJson( await fetchImpl( `/api/tenants/${encodeURIComponent(tenantId)}/members/invite`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(input), + body: JSON.stringify({ email: input.email, roleId }), }, ), "inviteMember", From aeefbd65b85915ea4655f9f54b6824aefdd53288 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 09:44:29 -0700 Subject: [PATCH 2/2] feat(web): installer driver becomes the client's only setup observer (CL-8131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboarding-page setup-pending phase now runs the portable client bootstrap directly: it mints the account's primary tenant over stock POST /api/tenants (no parentId, so the caller becomes owner — the 0->1 ruling's execution, never hub boot) and converges Myra onto it, landing on the shell once ready or showing the exact stock capability gap otherwise. The background bootstrap effect that used to run silently on every signed-in load in main.tsx is removed — the browser installer is now the only driver of the needs-list convergence. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- apps/web/src/app.tsx | 12 +++- apps/web/src/client-bootstrap.ts | 48 ++++++++++++- apps/web/src/main.tsx | 29 -------- apps/web/src/pages/onboarding-page.tsx | 96 ++++++++++++++++++++------ apps/web/test/onboarding.test.tsx | 79 +++++++++++++++++++-- 5 files changed, 204 insertions(+), 60 deletions(-) diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 13894c07d9..1b8f49a5d9 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -62,10 +62,16 @@ function LoginBounceHome({ navigate }: { readonly navigate: Navigate }) { * hub reports as setup-required must never see "Select a workbench"; the * setup screen is the only thing on screen until it hands off to `/`. */ -function OnboardingGate({ navigate }: { readonly navigate: Navigate }) { +function OnboardingGate({ + navigate, + user, +}: { + readonly navigate: Navigate; + readonly user: SessionUser; +}) { return ( - + ); } @@ -191,7 +197,7 @@ export function App({ return ; } if (path === ONBOARDING_PATH) { - return ; + return ; } return ( 0 ? base : `home-${account.id}`; +} + +/** First-signup installer step (CL-8131): mints the account's primary + * tenant over the stock `POST /api/tenants` route (no `parentId`, so the + * caller becomes its owner) when it does not already own one. This is + * the "0→1" ruling's execution — never hub boot, never a CLI — and it + * never mints a second root: an account that already owns a top-level + * tenant is left alone. */ +export async function ensurePrimaryTenant( + account: ClientBootstrapAccount, + hub: StockHub, +): Promise { + const owned = await findOwnedTenants(hub); + if (owned.some((tenant) => tenant.parentId === null)) return; + await hub.createTenant({ + name: `${account.name}'s Workbench`, + slug: primaryTenantSlug(account), + }); +} + /** The seeded assistant asset, productized as Myra — the same wire * identifier the hub seed deploys under and the catalog resolves to the * "Myra" display name. Resolved from the catalog at runtime, never @@ -125,7 +156,22 @@ export async function bootstrapClientSession( }); const store = childTenantStore(deps.storage, deps.hubScope, account.id); try { - const report = await convergeNeedsList(manifest, deps.hub, store); + // The common case (an account that already owns its primary tenant) + // never pays for a primary-tenant existence probe: convergence is + // attempted directly, and only a "primary-tenant-bootstrap" gap — the + // first-signup case — triggers the one-time mint-then-retry below. + const report = await convergeNeedsList(manifest, deps.hub, store).catch( + async (cause: unknown) => { + if ( + !(cause instanceof StockHubCapabilityError) || + cause.capability !== "primary-tenant-bootstrap" + ) { + throw cause; + } + await ensurePrimaryTenant(account, deps.hub); + return convergeNeedsList(manifest, deps.hub, store); + }, + ); return { kind: "ready", primaryTenantId: report.primaryTenantId, diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 1e02a42496..a2569fb590 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,11 +9,6 @@ import { createRoot } from "react-dom/client"; import { getLogger } from "@corbits/client-log"; import { AppErrorBoundary } from "./app-error-boundary"; import { App } from "./app"; -import { - logBootstrapResult, - logBootstrapThrown, - runPortableClientBootstrap, -} from "./client-bootstrap"; import { validatedNextPath } from "./login-next"; import { triggerFirstLoginProvisioning } from "./onboarding"; import { ONBOARDING_PATH } from "./routes"; @@ -86,30 +81,6 @@ function Root() { runProvisioning(); }, [runProvisioning]); - // The portable client lane: every signed-in open also converges the - // client's needs-list against stock Interchange routes and persists the - // child tenant ids it created (scoped by hub origin and account). It - // never gates the shell — stock hubs cannot project workflow principals - // into child rooms by refId yet, so a capability gap is the normal - // outcome until that lands upstream; it is logged, not shown. - const signedInUser = session.kind === "signed-in" ? session.user : null; - useEffect(() => { - if (signedInUser === null) return; - let cancelled = false; - void runPortableClientBootstrap(signedInUser).then( - (result) => { - if (cancelled) return; - logBootstrapResult(log, result); - }, - (error) => { - if (cancelled) return; - logBootstrapThrown(log, error); - }, - ); - return () => { - cancelled = true; - }; - }, [signedInUser]); const handleSignOut = useCallback(() => { setSession({ kind: "signed-out" }); toast( diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 7f63328b2c..8f2b364224 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -1,38 +1,46 @@ -// The setup gate (CL-8112): the screen a signed-in session lands on when -// the hub reports setup-required. It reads the hub's native setup-status -// route and does one of three honest things — a hub that turns out to -// already have tenants bounces straight into the shell (`/`), an empty -// hub renders a static pending panel, and a broken status read blocks -// with retry. Read-only on purpose: the provisioning, credential, and -// OAuth-connect machinery that used to live here spoke to the deleted -// `/api/onboarding/*` routes, so it went with the hub mount — the setup -// flow itself (first workbench, first credential) is T6/T7's to build, -// and this screen is where it will land. +// The setup gate (CL-8112/CL-8131): the screen a signed-in session lands +// on when the hub reports setup-required. It reads the hub's native +// setup-status route, and a hub that already has tenants bounces +// straight into the shell (`/`). An empty hub drives the browser +// installer itself: `bootstrapClientSession` mints the account's primary +// tenant over the stock `POST /api/tenants` route and converges Myra +// onto it, all over stock routes — the hub never seeds anything. A +// converged install lands on `/`; a stock capability gap (most commonly +// "no myraDeploy configured yet") renders here so the operator sees +// exactly what stock Interchange is missing, with a retry; a broken +// status read blocks with retry too. import { Button, EmptyState } from "@corbits/react-ui"; import { WarningCircle } from "@corbits/icons"; import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { useCallback, useEffect, useState } from "react"; +import { runPortableClientBootstrap } from "../client-bootstrap"; import { useNavigate } from "../navigation"; import { triggerFirstLoginProvisioning } from "../onboarding"; import { OnboardingLayout } from "../onboarding/onboarding-layout"; +import type { SessionUser } from "../session"; type GateState = | { readonly phase: "checking" } - | { readonly phase: "setup-pending" } + | { readonly phase: "installing" } + | { + readonly phase: "setup-pending"; + readonly message: string; + readonly refId?: string; + } | { readonly phase: "error"; readonly message: string; readonly refId?: string; }; -export function OnboardingPage() { +export function OnboardingPage({ user }: { readonly user: SessionUser }) { const navigate = useNavigate(); const [state, setState] = useState({ phase: "checking" }); - // One status read per landing (plus each manual recheck): the hook's - // verdict decides everything, so there is no step to replay — a reload - // just re-reads. + // One status read per landing (plus each manual recheck): a hub that + // already has tenants means setup is done; an empty hub drives the + // installer immediately rather than showing a dead-end panel. const checkStatus = useCallback(() => { setState({ phase: "checking" }); void triggerFirstLoginProvisioning().then((result) => { @@ -49,7 +57,7 @@ export function OnboardingPage() { } else if (result.kind === "existing-member") { navigate("/"); } else { - setState({ phase: "setup-pending" }); + setState({ phase: "installing" }); } }); }, [navigate]); @@ -57,6 +65,40 @@ export function OnboardingPage() { checkStatus(); }, [checkStatus]); + // The installer itself: runs once the status read confirms setup is + // required. A converged install hands off to the shell; a stock + // capability gap or a hard failure surfaces here instead of retrying + // silently forever. + useEffect(() => { + if (state.phase !== "installing") return; + let cancelled = false; + void runPortableClientBootstrap(user).then( + (result) => { + if (cancelled) return; + if (result.kind === "ready") { + navigate("/"); + } else if (result.code === "stock-capability-missing") { + setState({ phase: "setup-pending", message: result.gap }); + } else { + setState({ phase: "error", message: result.message }); + } + }, + (error: unknown) => { + if (cancelled) return; + setState({ + phase: "error", + message: + error instanceof Error + ? error.message + : "Setting up your workbench hit a snag.", + }); + }, + ); + return () => { + cancelled = true; + }; + }, [state.phase, user, navigate]); + if (state.phase === "checking") { return ( @@ -74,15 +116,29 @@ export function OnboardingPage() { ); } + if (state.phase === "installing") { + return ( + +
+

Setting up your workbench

+

One moment.

+
+ +
+
+
+ ); + } + if (state.phase === "setup-pending") { return (

Set up your workbench

-

- Your workbench isn't set up yet. The first-workbench setup flow - is still being built; this screen will host it. -

+

{state.message}