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
32 changes: 30 additions & 2 deletions packages/onboarding/src/complete-credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,18 +198,39 @@ export type CompleteCredentialArgs = CommonArgs &
seedTenantFn?: (args: SeedTenantArgs) => ReturnType<typeof seedTenant>;
};

/**
* Resolves the tenant a caller's bench work lands on. An exact slug match
* against `expectedSlug` (the computed personal-bench slug) always wins.
*
* By default the match is strict: a mismatch resolves to `undefined`.
* The boot seeder (`apps/hub/src/system-seed.ts`) depends on that — it
* must keep throwing until the root bench is actually visible to the
* admin, never seed onto the first principal that happens to be visible.
*
* The connect-flow callers (`testAndPersistCredential`, the OAuth
* duplicate-callback recovery `recentlyConnectedCredential`, and the
* `/complete-setup` route) pass `fallbackToFirstPrincipal` so a caller
* that already has a bench but under a different slug — a seeded admin
* whose only membership is the root bench (CL-7506) — resolves to the
* first principal in page order instead of hard-failing
* `no-personal-bench`. Only a response with zero principals yields
* `undefined`, with or without the flag.
*/
export async function findPersonalTenant(
api: ApiCall,
cookies: string[],
expectedSlug: string,
opts: { fallbackToFirstPrincipal?: boolean } = {},
): Promise<PersonalTenant | undefined> {
const response = await api("GET", "/api/me/principals", undefined, cookies);
const summary = parseAs(
paginatedSchema(PrincipalSummary),
response.data,
"principals response",
);
const own = summary.data.find((p) => p.tenantSlug === expectedSlug);
const own =
summary.data.find((p) => p.tenantSlug === expectedSlug) ??
(opts.fallbackToFirstPrincipal ? summary.data[0] : undefined);
if (!own) return undefined;

const tenantResponse = await api(
Expand Down Expand Up @@ -408,7 +429,14 @@ export async function testAndPersistCredential(
args: TestAndPersistCredentialArgs,
): Promise<TestAndPersistCredentialResult> {
const expectedSlug = personalTenantSlug(args.userEmail, args.userId);
const tenant = await findPersonalTenant(args.api, args.cookies, expectedSlug);
const tenant = await findPersonalTenant(
args.api,
args.cookies,
expectedSlug,
{
fallbackToFirstPrincipal: true,
},
);
if (!tenant) return { kind: "no-personal-bench" };

const descriptor = CONNECTOR_REGISTRY[args.provider];
Expand Down
13 changes: 11 additions & 2 deletions packages/onboarding/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,14 @@ async function recentlyConnectedCredential(
const now = args.now ?? Date.now;
try {
const expectedSlug = personalTenantSlug(args.userEmail, args.userId);
const tenant = await findPersonalTenant(api, cookies, expectedSlug);
// The recovery check serves the same connect flow as
// `testAndPersistCredential`, so it resolves through the same
// fallback — a seeded admin (root bench only) whose browser
// double-fires the callback must recover as connected, not see a
// strict miss turn into `state_expired` (CL-7506).
const tenant = await findPersonalTenant(api, cookies, expectedSlug, {
fallbackToFirstPrincipal: true,
});
if (!tenant) return undefined;

const listed = await api(
Expand Down Expand Up @@ -892,7 +899,9 @@ export function createOnboardingRoutes(
let tenantId: string | undefined;
try {
const expectedSlug = personalTenantSlug(user.email, user.id);
const tenant = await findPersonalTenant(api, cookies, expectedSlug);
const tenant = await findPersonalTenant(api, cookies, expectedSlug, {
fallbackToFirstPrincipal: true,
});
if (!tenant) {
return c.json(
makeErrorEnvelope({
Expand Down
173 changes: 173 additions & 0 deletions packages/onboarding/test/complete-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { pristineScheduledDefinitionHandshake } from "../../seeding/test/helpers
import {
completeCredentialSetup,
ensureSeeded,
findPersonalTenant,
modelSourceFor,
testAndPersistCredential,
} from "../src/complete-credential";
Expand Down Expand Up @@ -2086,3 +2087,175 @@ describe("ensureSeeded (the slow half)", () => {
).rejects.toThrow("the hub rejected the deployment with status 500");
});
});

// CL-7506: a seeded admin's only membership is the root bench (slug = the
// org's own slug), which never equals personalTenantSlug(userEmail,
// userId) — the connect flow must fall back to that existing principal
// rather than 409 with no_personal_bench. The fallback is opt-in: the
// boot seeder (apps/hub/src/system-seed.ts) calls with no options and
// depends on the strict match refusing to run until the root bench is
// actually visible to the admin.
describe("findPersonalTenant", () => {
function principalsPage(
principals: {
principalId: string;
tenantId: string;
tenantSlug: string;
}[],
) {
return {
status: 200,
data: {
data: principals.map((p) => ({
kind: "user",
status: "active",
roles: [],
tenantName: p.tenantSlug,
...p,
})),
nextCursor: null,
},
cookies: [],
};
}

function tenantFor(slug: string, domain: string) {
return {
status: 200,
data: {
id: slug,
name: slug,
slug,
domain,
parentId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
cookies: [],
};
}

function seedAdminHub() {
return async (method: string, path: string) => {
if (method === "GET" && path === "/api/me/principals") {
return principalsPage([
{
principalId: "prn_root",
tenantId: "ten_root",
tenantSlug: "acme",
},
]);
}
if (method === "GET" && path === "/api/tenants/ten_root") {
return tenantFor("acme", "acme.bench.local");
}
throw new Error(`unexpected call: ${method} ${path}`);
};
}

test("strict by default: a slug mismatch resolves to nothing, so the boot seeder keeps throwing until the root bench is visible", async () => {
// personalTenantSlug("alice@example.com", "user_1") is "alice-user1";
// the only principal is the root bench "acme". Without the fallback
// flag this must NOT silently resolve — system-seed pins on it.
expect(
await findPersonalTenant(seedAdminHub(), ["session=abc"], "alice-user1"),
).toBeUndefined();
});

test("with the fallback flag, the seeded-admin shape resolves to the root bench instead of no-personal-bench", async () => {
expect(
await findPersonalTenant(seedAdminHub(), ["session=abc"], "alice-user1", {
fallbackToFirstPrincipal: true,
}),
).toEqual({
tenantId: "ten_root",
tenantSlug: "acme",
principalId: "prn_root",
tenantDomain: "acme.bench.local",
});
});

test("a caller with zero principals still gets no-personal-bench, fallback or not", async () => {
const api: ApiCall = async (method, path) => {
if (method === "GET" && path === "/api/me/principals") {
return principalsPage([]);
}
throw new Error(`unexpected call: ${method} ${path}`);
};

expect(
await findPersonalTenant(api, ["session=abc"], "alice-user1"),
).toBeUndefined();
expect(
await findPersonalTenant(api, ["session=abc"], "alice-user1", {
fallbackToFirstPrincipal: true,
}),
).toBeUndefined();
});

test("an exact slug match wins even when a mismatched principal comes first", async () => {
const api: ApiCall = async (method, path) => {
if (method === "GET" && path === "/api/me/principals") {
return principalsPage([
{
principalId: "prn_root",
tenantId: "ten_root",
tenantSlug: "acme",
},
{
principalId: PRINCIPAL_ID,
tenantId: TENANT_ID,
tenantSlug: TENANT_SLUG,
},
]);
}
if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) {
return tenantFor(TENANT_SLUG, "alice-user1.bench.local");
}
throw new Error(`unexpected call: ${method} ${path}`);
};

expect(await findPersonalTenant(api, ["session=abc"], TENANT_SLUG)).toEqual(
{
tenantId: TENANT_ID,
tenantSlug: TENANT_SLUG,
principalId: PRINCIPAL_ID,
tenantDomain: "alice-user1.bench.local",
},
);
});

test("the fallback pick is the first principal in page order", async () => {
const api: ApiCall = async (method, path) => {
if (method === "GET" && path === "/api/me/principals") {
return principalsPage([
{
principalId: "prn_first",
tenantId: "ten_first",
tenantSlug: "first-bench",
},
{
principalId: "prn_second",
tenantId: "ten_second",
tenantSlug: "second-bench",
},
]);
}
if (method === "GET" && path === "/api/tenants/ten_first") {
return tenantFor("first-bench", "first.bench.local");
}
throw new Error(`unexpected call: ${method} ${path}`);
};

expect(
await findPersonalTenant(api, ["session=abc"], "alice-user1", {
fallbackToFirstPrincipal: true,
}),
).toEqual({
tenantId: "ten_first",
tenantSlug: "first-bench",
principalId: "prn_first",
tenantDomain: "first.bench.local",
});
});
});
61 changes: 61 additions & 0 deletions packages/onboarding/test/complete-setup-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,67 @@ describe("POST /complete-setup", () => {
}
});

// CL-7506: a seeded admin's only membership is the root bench, whose
// slug is the org's own — never the computed personal-bench slug. The
// fallback must resolve that principal here too, answering from the
// read instead of 409ing.
test("a root-bench-only admin (slug mismatch) resolves through the fallback and reports unseeded, not 409", async () => {
const hub = new Hono();
hub.get("/api/me/principals", (c) =>
c.json({
data: [
{
principalId: "prn_root",
tenantId: "ten_root",
tenantName: "acme",
tenantSlug: "acme",
kind: "user",
status: "active",
roles: [],
},
],
nextCursor: null,
}),
);
hub.get("/api/tenants/ten_root", (c) =>
c.json({
id: "ten_root",
name: "acme",
slug: "acme",
domain: "acme.bench.local",
parentId: null,
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
}),
);
hub.get("/api/tenants/ten_root/assets", (c) => c.json([]));
hub.get("/api/tenants/ten_root/workflows/deployments", (c) => c.json([]));
const server = Bun.serve({ port: 0, fetch: hub.fetch });
try {
const app = mountAuthenticated(
createOnboardingRoutes({
hubUrl: `http://localhost:${server.port}`,
pushWorkflow: async () => ({
outcome: "pushed" as const,
commitSha: "a".repeat(40),
}),
log: () => undefined,
pendingSeedStore: createInMemoryPendingSeedStore(testCipher()),
}),
);

const response = await app.request("/api/onboarding/complete-setup", {
method: "POST",
});

expect(response.status).toBe(200);
const body = (await response.json()) as { kind: string };
expect(body.kind).toBe("unseeded");
} finally {
server.stop(true);
}
});

test("an already fully seeded bench reports ready without needing a pending row", async () => {
const hub = new Hono();
principalsRoute(hub);
Expand Down
Loading
Loading