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
15 changes: 11 additions & 4 deletions apps/web/src/needs-converge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,18 +201,25 @@ export async function readHubSnapshot(hub: StockHub): Promise<HubSnapshot> {
};
}

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[];
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/onboarding.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
34 changes: 22 additions & 12 deletions apps/web/src/onboarding.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -74,10 +76,18 @@ export type ProvisionOutcome =
readonly refId?: string;
};

export async function triggerFirstLoginProvisioning(): Promise<ProvisionOutcome> {
export async function triggerFirstLoginProvisioning(
hub: StockHub = createFetchStockHub(),
): Promise<ProvisionOutcome> {
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 };
Expand Down
Loading