From 3923655970e4b6dc5602c214d75a012b4635370b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 19:34:11 -0700 Subject: [PATCH 1/2] test(web): first-login check needs Myra live, not just an owned tenant (CL-8412) --- apps/web/src/onboarding.test.ts | 43 +++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/web/src/onboarding.test.ts diff --git a/apps/web/src/onboarding.test.ts b/apps/web/src/onboarding.test.ts new file mode 100644 index 0000000000..82041b1ba2 --- /dev/null +++ b/apps/web/src/onboarding.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; + +import type { HubPrincipal, StockHub } from "./needs-converge"; +import { triggerFirstLoginProvisioning } from "./onboarding"; + +function hubWithPrimaryTenant(principals: readonly HubPrincipal[]): StockHub { + const tenant = { id: "tnt_home", name: "Home", slug: "home", parentId: null }; + return { + listMyPrincipals: async () => [ + { + id: "prn_me", + tenantId: tenant.id, + kind: "user", + status: "active", + roles: [{ id: "rol_owner", name: "owner" }], + }, + ], + getTenant: async () => tenant, + listPrincipals: async () => [...principals], + } as unknown as StockHub; +} + +const myra: HubPrincipal = { + id: "prn_myra", + tenantId: "tnt_home", + kind: "workflow", + refId: "wfl_myra", + displayName: "Myra", + status: "active", + roles: [], +}; + +describe("triggerFirstLoginProvisioning", () => { + test("an owned tenant without Myra still needs onboarding", async () => { + const outcome = await triggerFirstLoginProvisioning(hubWithPrimaryTenant([])); + expect(outcome).toEqual({ kind: "needs-onboarding" }); + }); + + test("an owned tenant with Myra live is an existing member", async () => { + const outcome = await triggerFirstLoginProvisioning(hubWithPrimaryTenant([myra])); + expect(outcome).toEqual({ kind: "existing-member" }); + }); +}); From a4a259129f48ca6da6a7a22687f056980979a2a9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 19:34:11 -0700 Subject: [PATCH 2/2] fix(web): setup gate resumes when the primary tenant has no Myra yet (CL-8412) --- apps/web/src/needs-converge.ts | 15 +++++++++++---- apps/web/src/onboarding.ts | 34 ++++++++++++++++++++++------------ 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/apps/web/src/needs-converge.ts b/apps/web/src/needs-converge.ts index 219b792a5e..9938b57121 100644 --- a/apps/web/src/needs-converge.ts +++ b/apps/web/src/needs-converge.ts @@ -201,18 +201,25 @@ export async function readHubSnapshot(hub: StockHub): Promise { }; } -function hasMyra(manifest: NeedsList, snapshot: HubSnapshot): boolean { - const expected = manifest.myra.definitionRefId.toLowerCase(); - return snapshot.primaryPrincipals.some( +export function hasActiveMyraPrincipal( + principals: readonly HubPrincipal[], + definitionRefId: string, +): boolean { + const expected = definitionRefId.toLowerCase(); + return principals.some( (principal) => principal.kind === "workflow" && principal.status === "active" && - (principal.refId === manifest.myra.definitionRefId || + (principal.refId === definitionRefId || principal.displayName.toLowerCase() === expected || principal.displayName.toLowerCase() === "myra"), ); } +function hasMyra(manifest: NeedsList, snapshot: HubSnapshot): boolean { + return hasActiveMyraPrincipal(snapshot.primaryPrincipals, manifest.myra.definitionRefId); +} + export type ConvergeReport = { primaryTenantId: string; createdTenantIds: string[]; diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index df40e2319c..bba6e39167 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -1,15 +1,17 @@ -// The browser side of the first-login hook: reads the client converge -// state instead of a hub-served setup-status route — a hub with -// zero owned tenants means setup-required so the UI routes into the setup -// wizard; any owned tenant means a bench exists and the shell loads -// normally. Read-only on purpose: provisioning itself back onto -// the hub is T6/T7's surface, so this never mints anything — a failed -// read blocks the shell loudly rather than leaving the user silently -// benchless. +// The browser side of the first-login hook: a read-only probe that never +// mints anything. Setup counts as done only once the primary tenant has +// Myra live, so an install that failed midway resumes instead of landing +// in an empty shell. import { type } from "arktype"; import { reportError } from "@corbits/error-sink"; -import { createFetchStockHub, findOwnedTenants } from "./needs-converge"; +import { resolveMyraDefinitionRefId } from "./client-bootstrap"; +import { + createFetchStockHub, + findOwnedTenants, + hasActiveMyraPrincipal, + type StockHub, +} from "./needs-converge"; /** Any credential row this bench actually has stored — the cheap * pre-skip read the home page's first-workbench flow uses to tell "no @@ -74,10 +76,18 @@ export type ProvisionOutcome = readonly refId?: string; }; -export async function triggerFirstLoginProvisioning(): Promise { +export async function triggerFirstLoginProvisioning( + hub: StockHub = createFetchStockHub(), +): Promise { try { - const owned = await findOwnedTenants(createFetchStockHub()); - return owned.length === 0 ? { kind: "needs-onboarding" } : { kind: "existing-member" }; + const owned = await findOwnedTenants(hub); + const primary = owned.find((tenant) => tenant.parentId === null); + if (primary === undefined) return { kind: "needs-onboarding" }; + const principals = await hub.listPrincipals(primary.id); + const myraRefId = resolveMyraDefinitionRefId() ?? "myra"; + return hasActiveMyraPrincipal(principals, myraRefId) + ? { kind: "existing-member" } + : { kind: "needs-onboarding" }; } catch (cause) { const refId = reportError(cause, { operation: "first_login_provisioning" }); return { kind: "error", message: FALLBACK_ERROR_MESSAGE, refId };