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/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", 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}