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
12 changes: 9 additions & 3 deletions apps/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<NavigationProvider navigate={navigate}>
<OnboardingPage />
<OnboardingPage user={user} />
</NavigationProvider>
);
}
Expand Down Expand Up @@ -191,7 +197,7 @@ export function App({
return <LoginBounceHome navigate={navigate} />;
}
if (path === ONBOARDING_PATH) {
return <OnboardingGate navigate={navigate} />;
return <OnboardingGate navigate={navigate} user={session.user} />;
}
return (
<Shell
Expand Down
48 changes: 47 additions & 1 deletion apps/web/src/client-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,44 @@ import {
import {
convergeNeedsList,
createFetchStockHub,
findOwnedTenants,
StockHubCapabilityError,
StockHubRequestError,
type PrimaryThread,
type StockHub,
type StockHubCapability,
} from "./needs-converge";

/** Derives a stable, readable slug for the primary tenant this account is
* about to mint — never random, so a retry after a dropped response
* targets the same slug rather than minting a second root. */
function primaryTenantSlug(account: ClientBootstrapAccount): string {
const base = account.email
.split("@")[0]
?.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return base !== undefined && base.length > 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<void> {
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
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 0 additions & 29 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 30 additions & 4 deletions apps/web/src/needs-converge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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")),
Expand Down Expand Up @@ -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,
});
};

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
});
});
});
71 changes: 65 additions & 6 deletions apps/web/src/needs-converge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,26 @@ export type StockHub = {
listMyPrincipals(): Promise<MyMembership[]>;
getTenant(id: string): Promise<HubTenant | null>;
listPrincipals(tenantId: string): Promise<HubPrincipal[]>;
/** 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<HubTenant>;
inviteMember(tenantId: string, input: { email: string }): Promise<void>;
/**
* 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<void>;
deployWorkflow(tenantId: string, input: WorkflowDeployInput): Promise<void>;
sendRunMail(input: SendRunMailInput): Promise<{ messageId: string }>;
listRunMail(input: { tenantId: string }): Promise<MailMessage[]>;
Expand Down Expand Up @@ -151,19 +165,27 @@ export class StockHubRequestError extends Error {
}
}

export async function readHubSnapshot(hub: StockHub): Promise<HubSnapshot> {
/** 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<HubTenant[]> {
const memberships = await hub.listMyPrincipals();
const activeOwned = memberships.filter(
(membership) =>
membership.kind === "user" &&
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<HubSnapshot> {
const tenants = await findOwnedTenants(hub);
const primaryCandidates = tenants.filter(
(tenant) => tenant.parentId === null,
);
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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<string> {
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,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading