From 58fa2ab3e6893f58b631b511158a6f673a16911a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 01:29:05 -0700 Subject: [PATCH 1/5] Add tests for first-signup genesis and join --- packages/onboarding/src/genesis.ts | 179 ++++++++++ packages/onboarding/test/genesis.test.ts | 411 +++++++++++++++++++++++ 2 files changed, 590 insertions(+) create mode 100644 packages/onboarding/src/genesis.ts create mode 100644 packages/onboarding/test/genesis.test.ts diff --git a/packages/onboarding/src/genesis.ts b/packages/onboarding/src/genesis.ts new file mode 100644 index 000000000..dea8ea2db --- /dev/null +++ b/packages/onboarding/src/genesis.ts @@ -0,0 +1,179 @@ +// First empty-hub signup mints the root tenant (native owner = superadmin). +// Later signups join that tenant as member. This path never seeds workflows, +// tools, or grants. + +import { paginatedSchema, PrincipalSummary, TenantResponse } from "@intx/types"; +import { parseAs, type ApiCall } from "@corbits/hub-api-client"; +import type { AccessPolicyStore } from "@workbench/access-policy"; + +export type HubSignupTenancy = { + countUsers(): Promise; + countTenants(): Promise; + findRootTenant(): Promise<{ id: string; slug: string } | null>; + addActiveMember(args: { + tenantId: string; + userId: string; + roleName: "member"; + }): Promise<{ principalId: string }>; +}; + +export type GenesisOrJoinArgs = { + api: ApiCall; + cookies: string[]; + userId: string; + userEmail: string; + userEmailVerified: boolean; + defaultTenantSlug: string; + displayName?: string; + tenancy: HubSignupTenancy; + accessPolicy?: { + store: AccessPolicyStore; + envSignupMode: "open" | "closed"; + envAllowedDomains: readonly string[]; + allowUnverifiedEmails: boolean; + }; + log: (line: string) => void; +}; + +export type GenesisOrJoinResult = + | { + readonly kind: "genesis"; + readonly tenantId: string; + readonly tenantSlug: string; + } + | { + readonly kind: "joined"; + readonly tenantId: string; + readonly tenantSlug: string; + readonly principalId: string; + } + | { readonly kind: "existing-member" } + | { readonly kind: "needs-onboarding" }; + +export type ProvisionErrorKind = "transient" | "permanent"; + +export class ProvisionError extends Error { + readonly code: string; + readonly errorKind: ProvisionErrorKind; + constructor(code: string, message: string, errorKind: ProvisionErrorKind) { + super(message); + this.name = "ProvisionError"; + this.code = code; + this.errorKind = errorKind; + } +} + +async function fetchPrincipals( + api: ApiCall, + cookies: string[], +): Promise<{ tenantId: string; tenantSlug: string; principalId: string }[]> { + const response = await api("GET", "/api/me/principals", undefined, cookies); + const summary = parseAs( + paginatedSchema(PrincipalSummary), + response.data, + "principals response", + ); + return summary.data.map((p) => ({ + tenantId: p.tenantId, + tenantSlug: p.tenantSlug, + principalId: p.principalId, + })); +} + +async function joinRoot( + args: GenesisOrJoinArgs, + root: { id: string; slug: string }, +): Promise { + const { principalId } = await args.tenancy.addActiveMember({ + tenantId: root.id, + userId: args.userId, + roleName: "member", + }); + args.log( + `joined existing hub tenant ${root.slug} (${root.id}) as member principal ${principalId}`, + ); + return { + kind: "joined", + tenantId: root.id, + tenantSlug: root.slug, + principalId, + }; +} + +async function requireRoot(tenancy: HubSignupTenancy): Promise<{ + id: string; + slug: string; +}> { + const root = await tenancy.findRootTenant(); + if (root === null) { + throw new ProvisionError( + "root_tenant_missing", + "signup join path found no root tenant (parentId IS NULL)", + "permanent", + ); + } + return root; +} + +export async function genesisOrJoinHubSignup( + args: GenesisOrJoinArgs, +): Promise { + const before = await fetchPrincipals(args.api, args.cookies); + if (before.length > 0) return { kind: "existing-member" }; + + const tenantCount = await args.tenancy.countTenants(); + const userCount = await args.tenancy.countUsers(); + if (tenantCount > 0 || userCount > 1) { + return joinRoot(args, await requireRoot(args.tenancy)); + } + + if (args.displayName === undefined || args.displayName.trim().length === 0) { + return { kind: "needs-onboarding" }; + } + + const created = await args.api( + "POST", + "/api/tenants", + { + name: args.displayName.trim(), + slug: args.defaultTenantSlug, + }, + args.cookies, + ); + if (created.status === 409) { + const root = await args.tenancy.findRootTenant(); + if (root !== null) return joinRoot(args, root); + const afterRace = await fetchPrincipals(args.api, args.cookies); + if (afterRace.length > 0) return { kind: "existing-member" }; + throw new ProvisionError( + "slug_conflict_no_principal", + `first-login provisioning hit a slug conflict creating a personal bench, but the caller still has no principal anywhere: ${JSON.stringify(created.data)}`, + "permanent", + ); + } + if (created.status !== 201) { + throw new ProvisionError( + "tenant_create_failed", + `first-login provisioning could not create a personal bench (status ${created.status}): ${JSON.stringify(created.data)}`, + created.status >= 500 ? "transient" : "permanent", + ); + } + const tenant = parseAs(TenantResponse, created.data, "tenant response"); + const after = await fetchPrincipals(args.api, args.cookies); + const membership = after.find((p) => p.tenantId === tenant.id); + if (membership === undefined) { + throw new ProvisionError( + "tenant_created_no_membership", + `personal bench ${tenant.id} was created but the caller has no principal in it`, + "transient", + ); + } + args.log( + `genesis tenant ${tenant.slug} (${tenant.id}) minted for ${args.userEmail}`, + ); + return { + kind: "genesis", + tenantId: tenant.id, + tenantSlug: tenant.slug, + }; +} diff --git a/packages/onboarding/test/genesis.test.ts b/packages/onboarding/test/genesis.test.ts new file mode 100644 index 000000000..87e42b420 --- /dev/null +++ b/packages/onboarding/test/genesis.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, test } from "bun:test"; +import type { ApiCall } from "@corbits/hub-api-client"; +import type { AccessPolicyStore } from "@workbench/access-policy"; +import { + genesisOrJoinHubSignup, + ProvisionError, + type GenesisOrJoinArgs, + type HubSignupTenancy, +} from "../src/genesis"; + +const TENANT_ID = "ten_root"; +const TENANT_SLUG = "workbench"; +const PRINCIPAL_ID = "prn_owner"; +const MEMBER_PRINCIPAL_ID = "prn_member"; + +function collector() { + const lines: string[] = []; + return { lines, log: (line: string) => lines.push(line) }; +} + +function throwingAccessPolicy(): NonNullable< + GenesisOrJoinArgs["accessPolicy"] +> { + const store = { + getPolicy: async () => { + throw new Error("checkSignupGate must not run for genesis or join"); + }, + } as unknown as AccessPolicyStore; + return { + store, + envSignupMode: "closed", + envAllowedDomains: [], + allowUnverifiedEmails: false, + }; +} + +function tenancy(state: { + users?: number; + tenants?: number; + root?: { id: string; slug: string } | null; + joins?: { tenantId: string; userId: string; roleName: string }[]; +}): HubSignupTenancy { + const joins = state.joins ?? []; + return { + countUsers: async () => state.users ?? 0, + countTenants: async () => state.tenants ?? 0, + findRootTenant: async () => (state.root === undefined ? null : state.root), + addActiveMember: async (args) => { + joins.push(args); + return { principalId: MEMBER_PRINCIPAL_ID }; + }, + }; +} + +function principalsResponse( + rows: { + principalId: string; + tenantId: string; + tenantSlug: string; + }[], +) { + return { + status: 200, + data: { + data: rows.map((row) => ({ + principalId: row.principalId, + tenantId: row.tenantId, + tenantName: "Workbench", + tenantSlug: row.tenantSlug, + kind: "user", + status: "active", + roles: [], + })), + nextCursor: null, + }, + cookies: [], + }; +} + +function argsFor(partial: { + api: ApiCall; + tenancy: HubSignupTenancy; + displayName?: string; + log?: (line: string) => void; +}): GenesisOrJoinArgs { + const args: GenesisOrJoinArgs = { + api: partial.api, + cookies: ["session=abc"], + userId: "user_1", + userEmail: "alice@example.com", + userEmailVerified: true, + defaultTenantSlug: TENANT_SLUG, + tenancy: partial.tenancy, + accessPolicy: throwingAccessPolicy(), + log: partial.log ?? collector().log, + }; + if (partial.displayName !== undefined) args.displayName = partial.displayName; + return args; +} + +describe("genesisOrJoinHubSignup", () => { + test("nonempty principals is existing-member: no tenant create, no join, no seed", async () => { + const joins: { tenantId: string; userId: string; roleName: string }[] = []; + let tenantCreates = 0; + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse([ + { + principalId: PRINCIPAL_ID, + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + }, + ]); + } + if (method === "POST" && path === "/api/tenants") { + tenantCreates += 1; + throw new Error("must not mint a tenant for an existing member"); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await genesisOrJoinHubSignup( + argsFor({ + api, + tenancy: tenancy({ + users: 1, + tenants: 1, + root: { id: TENANT_ID, slug: TENANT_SLUG }, + joins, + }), + displayName: "Acme", + }), + ); + + expect(result).toEqual({ kind: "existing-member" }); + expect(tenantCreates).toBe(0); + expect(joins).toEqual([]); + }); + + test("an existing tenant joins the caller as member and never POSTs /api/tenants", async () => { + const joins: { tenantId: string; userId: string; roleName: string }[] = []; + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse([]); + } + if (method === "POST" && path === "/api/tenants") { + throw new Error("join must never mint a tenant"); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await genesisOrJoinHubSignup( + argsFor({ + api, + tenancy: tenancy({ + users: 2, + tenants: 1, + root: { id: TENANT_ID, slug: TENANT_SLUG }, + joins, + }), + displayName: "Bob", + }), + ); + + expect(result).toEqual({ + kind: "joined", + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + principalId: MEMBER_PRINCIPAL_ID, + }); + expect(joins).toEqual([ + { tenantId: TENANT_ID, userId: "user_1", roleName: "member" }, + ]); + }); + + test("countUsers > 1 with zero tenants still joins the root rather than minting", async () => { + const joins: { tenantId: string; userId: string; roleName: string }[] = []; + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse([]); + } + if (method === "POST" && path === "/api/tenants") { + throw new Error("must not mint when another user already exists"); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await genesisOrJoinHubSignup( + argsFor({ + api, + tenancy: tenancy({ + users: 2, + tenants: 0, + root: { id: TENANT_ID, slug: TENANT_SLUG }, + joins, + }), + displayName: "Bob", + }), + ); + + expect(result.kind).toBe("joined"); + expect(joins).toHaveLength(1); + }); + + test("zero tenants and no display name returns needs-onboarding and creates nothing", async () => { + let tenantCreates = 0; + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse([]); + } + if (method === "POST" && path === "/api/tenants") { + tenantCreates += 1; + throw new Error("must not create without a display name"); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await genesisOrJoinHubSignup( + argsFor({ + api, + tenancy: tenancy({ users: 1, tenants: 0, root: null }), + }), + ); + + expect(result).toEqual({ kind: "needs-onboarding" }); + expect(tenantCreates).toBe(0); + }); + + test("blank display name is needs-onboarding", async () => { + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse([]); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await genesisOrJoinHubSignup( + argsFor({ + api, + tenancy: tenancy({ users: 1, tenants: 0, root: null }), + displayName: " ", + }), + ); + + expect(result).toEqual({ kind: "needs-onboarding" }); + }); + + test("empty hub genesis POSTs /api/tenants with name and default slug and no parentId", async () => { + let principalsCalls = 0; + const bodies: unknown[] = []; + const api: ApiCall = async (method, path, body) => { + if (path.includes("/assets") || path.includes("/workflows")) { + throw new Error(`signup must not seed: ${method} ${path}`); + } + if (method === "GET" && path === "/api/me/principals") { + principalsCalls += 1; + if (principalsCalls === 1) return principalsResponse([]); + return principalsResponse([ + { + principalId: PRINCIPAL_ID, + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + }, + ]); + } + if (method === "POST" && path === "/api/tenants") { + bodies.push(body); + return { + status: 201, + data: { + id: TENANT_ID, + name: "Acme", + slug: TENANT_SLUG, + domain: `${TENANT_SLUG}.localhost`, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await genesisOrJoinHubSignup( + argsFor({ + api, + tenancy: tenancy({ users: 1, tenants: 0, root: null }), + displayName: "Acme", + }), + ); + + expect(result).toEqual({ + kind: "genesis", + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + }); + expect(bodies).toEqual([{ name: "Acme", slug: TENANT_SLUG }]); + }); + + test("genesis 409 when a root exists joins as member", async () => { + const joins: { tenantId: string; userId: string; roleName: string }[] = []; + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse([]); + } + if (method === "POST" && path === "/api/tenants") { + return { + status: 409, + data: { error: { code: "conflict", message: "Slug already taken" } }, + cookies: [], + }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await genesisOrJoinHubSignup( + argsFor({ + api, + tenancy: tenancy({ + users: 1, + tenants: 0, + root: { id: TENANT_ID, slug: TENANT_SLUG }, + joins, + }), + displayName: "Acme", + }), + ); + + expect(result).toEqual({ + kind: "joined", + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + principalId: MEMBER_PRINCIPAL_ID, + }); + expect(joins).toHaveLength(1); + }); + + test("genesis 409 with no root and no principal is slug_conflict_no_principal", async () => { + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse([]); + } + if (method === "POST" && path === "/api/tenants") { + return { + status: 409, + data: { error: { code: "conflict", message: "Slug already taken" } }, + cookies: [], + }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + await expect( + genesisOrJoinHubSignup( + argsFor({ + api, + tenancy: tenancy({ users: 1, tenants: 0, root: null }), + displayName: "Acme", + }), + ), + ).rejects.toMatchObject({ + name: "ProvisionError", + code: "slug_conflict_no_principal", + }); + }); + + test("closed accessPolicy is never consulted for genesis or join", async () => { + let principalsCalls = 0; + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + principalsCalls += 1; + if (principalsCalls === 1) return principalsResponse([]); + return principalsResponse([ + { + principalId: PRINCIPAL_ID, + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + }, + ]); + } + if (method === "POST" && path === "/api/tenants") { + return { + status: 201, + data: { + id: TENANT_ID, + name: "Acme", + slug: TENANT_SLUG, + domain: `${TENANT_SLUG}.localhost`, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + await genesisOrJoinHubSignup( + argsFor({ + api, + tenancy: tenancy({ users: 1, tenants: 0, root: null }), + displayName: "Acme", + }), + ); + }); +}); + +test("ProvisionError is constructible for route mapping", () => { + const error = new ProvisionError("tenant_create_failed", "nope", "transient"); + expect(error.errorKind).toBe("transient"); +}); From 9366763f4dba7fcd11cf15658bb2e3b1b2363616 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 01:52:53 -0700 Subject: [PATCH 2/5] First signup creates the first tenant; later signups join --- apps/hub/src/config.ts | 4 + apps/hub/src/index.ts | 61 +- apps/hub/src/signup-tenancy.ts | 95 + apps/hub/src/tenant-create-guard.ts | 11 + apps/hub/test/signup-genesis.test.ts | 318 +++ apps/hub/test/tenant-create-guard.test.ts | 25 + packages/onboarding/src/index.ts | 7 + packages/onboarding/src/provision.ts | 362 +-- packages/onboarding/src/routes.ts | 20 +- .../test/complete-setup-routes.test.ts | 35 + .../test/connect-deploys-nothing.test.ts | 11 + .../test/huggingface-connect-routes.test.ts | 11 + .../test/openrouter-connect-routes.test.ts | 11 + packages/onboarding/test/provision.test.ts | 1994 ++--------------- .../test/report-error-routing.test.ts | 17 + packages/onboarding/test/routes.test.ts | 41 + 16 files changed, 883 insertions(+), 2140 deletions(-) create mode 100644 apps/hub/src/signup-tenancy.ts create mode 100644 apps/hub/test/signup-genesis.test.ts diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index ead48dabe..8ed04a5d4 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -393,6 +393,10 @@ export type HubConfig = { * see `ROUTINE_SCHEDULER_POLL_INTERVAL_MS` above. Unset runs the real * production cadence (`routine-scheduler.ts`'s own default). */ readonly routineSchedulerPollIntervalMs?: number; + /** Test-only seam to skip the boot-time `ensureDefaultTenant` call so + * a suite can exercise the true empty-hub first-signup path (CL-7578). + * Never set by `readHubConfig` and never set for a real deployment. */ + readonly skipEnsureDefaultTenant?: boolean; /** Every sidecar-allocation backend registered for exclusive placement, * one or more, each addressable by its provisioner id. Never empty: an * install that configures nothing registers the `process` backend, so diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 5c3c4e9f0..c32a6ea9c 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -163,6 +163,7 @@ import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes"; import { generateId } from "@intx/hub-common"; import { ensureDefaultTenant } from "./default-tenant"; +import { createHubSignupTenancy } from "./signup-tenancy"; import { runSystemSeed } from "./system-seed"; import { createInMemoryMailboxEventBus, @@ -669,6 +670,10 @@ export async function createHub(config: HubConfig) { // Per-principal signing keys are sealed under their own operator key — // see `principalKeyStoreFrom`. const principalKeyStore = principalKeyStoreFrom(config, db, log); + // The genesis-or-join first-signup decision's tenancy reads/writes — + // also read by the sign-up gate (empty-hub exception) and the + // tenant-create guard below. See ./signup-tenancy.ts. + const signupTenancy = createHubSignupTenancy(db, principalKeyStore); const auth = betterAuth({ baseURL: config.baseUrl, @@ -741,14 +746,19 @@ export async function createHub(config: HubConfig) { // membership too — `workbench setup` then adopts the root instead of // colliding with it, and the root's policy row has an editor. Failure // here fails the boot loudly — a hub without its root tenant cannot - // serve first logins. - const operatorTenantId = await ensureDefaultTenant( - db, - auth, - config.envCredentialPlantAdmin, - config.defaultTenantSlug, - principalKeyStore, - ); + // serve first logins. The skip seam is test-only: a suite exercising + // the true empty-hub first-signup path sets it so the first signup + // itself mints the root (CL-7578); readHubConfig never sets it. + const operatorTenantId = + config.skipEnsureDefaultTenant === true + ? undefined + : await ensureDefaultTenant( + db, + auth, + config.envCredentialPlantAdmin, + config.defaultTenantSlug, + principalKeyStore, + ); // Account-keyed sign-in rate limit (CL-6494) — see `sign-in-rate-limit.ts` // for why this replaces better-auth's own IP-keyed sign-in enforcement // entirely rather than composing with it. @@ -1272,14 +1282,24 @@ export async function createHub(config: HubConfig) { // sign-up/email path is product-controlled (docs/TENANCY.md). if (c.req.method === "POST" && c.req.path.endsWith(SIGN_UP_EMAIL_PATH)) { if (config.signupMode === "closed") { - return c.json( - { - error: "signup_closed", - message: - "Self-serve signup is disabled. Ask an owner for an invite.", - }, - 403, - ); + // Empty-hub exception (CL-7578): with zero users and zero + // tenants, someone has to be first — the signup that opens a + // brand-new hub is allowed even when signup is closed, since + // the genesis path makes that caller the root tenant's owner. + const [users, tenants] = await Promise.all([ + signupTenancy.countUsers(), + signupTenancy.countTenants(), + ]); + if (users > 0 || tenants > 0) { + return c.json( + { + error: "signup_closed", + message: + "Self-serve signup is disabled. Ask an owner for an invite.", + }, + 403, + ); + } } if (config.allowedEmailDomains.length > 0) { let email = ""; @@ -3378,6 +3398,8 @@ export async function createHub(config: HubConfig) { const onboardingDeps: Parameters[0] = { hubUrl: config.baseUrl, + defaultTenantSlug: config.defaultTenantSlug, + tenancy: signupTenancy, pushWorkflow: createGitWorkflowPusher(), log: (line) => log.info`${line}`, logError: (line) => log.error`${line}`, @@ -3396,9 +3418,6 @@ export async function createHub(config: HubConfig) { // routed someone to onboarding to fix. providerHealth: providerHealthStore, }; - onboardingDeps.operatorTenantId = operatorTenantId; - if (config.seedModel !== undefined) - onboardingDeps.seedModel = config.seedModel; if (config.huggingfaceOAuthClientId !== undefined) onboardingDeps.huggingfaceClientId = config.huggingfaceOAuthClientId; @@ -3573,6 +3592,7 @@ export async function createHub(config: HubConfig) { store: accessPolicyStore, resolveCallerRoleNames: (tenantId, userId) => resolveCallerRoleNames(db, tenantId, userId), + countTenants: signupTenancy.countTenants, envSignupMode: config.signupMode, envAllowedDomains: config.allowedEmailDomains, allowUnverifiedEmails: config.allowUnverifiedEmails, @@ -3587,7 +3607,8 @@ export async function createHub(config: HubConfig) { : undefined; }, }; - guardDeps.operatorTenantId = operatorTenantId; + if (operatorTenantId !== undefined) + guardDeps.operatorTenantId = operatorTenantId; const guardedApp = guardedHubApp(app, guardDeps); const inFlight = createInFlightRequestTracker(); const servingApp = withInFlightRequestTracking(guardedApp, inFlight); diff --git a/apps/hub/src/signup-tenancy.ts b/apps/hub/src/signup-tenancy.ts new file mode 100644 index 000000000..89eb90eb0 --- /dev/null +++ b/apps/hub/src/signup-tenancy.ts @@ -0,0 +1,95 @@ +// The hub's `HubSignupTenancy` adapter (see +// packages/onboarding/src/genesis.ts): the native reads and writes the +// genesis-or-join first-signup decision needs — user/tenant counts, +// the root-tenant lookup, and the member-role join. Mirrors +// packages/chat/src/workbench-tenancy.ts's `addWorkbenchMember` +// (principalStore.create + principalRole "member") so a joined signup +// holds exactly the membership shape a workbench member holds. Reads +// native tables only; declares none of its own (see +// scripts/checks/no-product-tenancy.ts). +import { and, count, eq, isNull } from "drizzle-orm"; +import { generateId } from "@intx/hub-common"; +import { + createPrincipalStore, + type DB, + type PrincipalKeyStore, +} from "@intx/db"; +import { + principal, + principalRole, + role, + tenant, + user as userTable, +} from "@intx/db/schema"; +import type { HubSignupTenancy } from "@workbench/onboarding"; + +export function createHubSignupTenancy( + db: DB["db"], + principalKeyStore: PrincipalKeyStore, +): HubSignupTenancy { + const principalStore = createPrincipalStore(db, principalKeyStore); + return { + countUsers: async () => { + const [row] = await db.select({ n: count() }).from(userTable); + return row?.n ?? 0; + }, + countTenants: async () => { + const [row] = await db.select({ n: count() }).from(tenant); + return row?.n ?? 0; + }, + findRootTenant: async () => { + const [row] = await db + .select({ id: tenant.id, slug: tenant.slug }) + .from(tenant) + .where(isNull(tenant.parentId)) + .limit(1); + return row ?? null; + }, + addActiveMember: async ({ tenantId, userId, roleName }) => + db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: principal.id }) + .from(principal) + .where( + and( + eq(principal.tenantId, tenantId), + eq(principal.kind, "user"), + eq(principal.refId, userId), + ), + ) + .limit(1); + if (existing !== undefined) return { principalId: existing.id }; + + const [memberRole] = await tx + .select({ id: role.id }) + .from(role) + .where(and(eq(role.tenantId, tenantId), eq(role.name, roleName))) + .limit(1); + if (memberRole === undefined) { + throw new Error( + `signup join: tenant "${tenantId}" has no "${roleName}" system role`, + ); + } + + const now = new Date(); + const created = await principalStore.create( + { + id: generateId("principal"), + tenantId, + kind: "user", + refId: userId, + status: "active", + createdAt: now, + updatedAt: now, + }, + tx, + ); + await tx.insert(principalRole).values({ + principalId: created.id, + roleId: memberRole.id, + createdAt: now, + }); + return { principalId: created.id }; + }), + }; +} diff --git a/apps/hub/src/tenant-create-guard.ts b/apps/hub/src/tenant-create-guard.ts index e9d696373..602e9dd13 100644 --- a/apps/hub/src/tenant-create-guard.ts +++ b/apps/hub/src/tenant-create-guard.ts @@ -43,6 +43,9 @@ export type TenantCreateGuardDeps = { tenantId: string, userId: string, ) => Promise; + /** Total native tenant count — the hub's signup-tenancy adapter's own + * read, injected so the decision stays DB-free in tests. */ + countTenants: () => Promise; operatorTenantId?: string; envSignupMode: "open" | "closed"; envAllowedDomains: readonly string[]; @@ -115,6 +118,14 @@ export async function decideTenantCreate( request.parentId === undefined || request.parentId === deps.operatorTenantId ) { + // Genesis on an empty hub (CL-7578): with zero tenants there is no + // policy, no operator tenant, and nobody to invite anyone — the + // first signup's unparented create is the one path that bypasses + // the signup gate, because the sign-up route's own empty-hub + // exception already admitted this caller. + if (request.parentId === undefined && (await deps.countTenants()) === 0) { + return { allowed: true }; + } type MutableSignupGateArgs = { -readonly [K in keyof Parameters[0]]: Parameters< typeof checkSignupGate diff --git a/apps/hub/test/signup-genesis.test.ts b/apps/hub/test/signup-genesis.test.ts new file mode 100644 index 000000000..c8de648fc --- /dev/null +++ b/apps/hub/test/signup-genesis.test.ts @@ -0,0 +1,318 @@ +// CL-7578 end-to-end proof of the 0→1 contract: a hub booted with the +// `skipEnsureDefaultTenant` seam starts truly empty, and the first +// signup — not a CLI, not a boot-time seed — mints the root tenant and +// becomes its owner. The second signup joins that root as a plain +// member. Signup never seeds workflows, tools, or grants. +// +// DB-gated: each test boots a full hub against its own scratch +// database (the default-tenant.test.ts recipe), so a reachable +// DATABASE_URL is required and the suite skips without one. +import { afterAll, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import postgres from "postgres"; +import { and, eq, isNull } from "drizzle-orm"; +import { + principal, + principalRole, + role, + tenant, + user as userTable, +} from "@intx/db/schema"; +import type { HubConfig } from "../src/config.ts"; +import { createHub } from "../src/index.ts"; +import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; +import { setupDatabase } from "../../../scripts/db-setup"; +import { dbGate } from "../../../scripts/e2e/db-gate"; + +const databaseUrl = e2eDatabaseUrl(); +const describeIfDb = dbGate(databaseUrl, import.meta.path); + +const closers: (() => Promise)[] = []; +afterAll(async () => { + let closer: (() => Promise) | undefined; + while ((closer = closers.pop()) !== undefined) await closer(); +}); + +function scratchUrlFor(label: string): string { + const url = new URL(databaseUrl ?? "postgres://localhost:5432/unused"); + const database = url.pathname.replace(/^\//, ""); + url.pathname = `/${database}_signup_genesis_${label}`; + return url.toString(); +} + +async function withScratchDatabase( + scratchUrl: string, + run: () => Promise, +): Promise { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + await setupDatabase(scratchUrl); + try { + await run(); + } finally { + const cleanup = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await cleanup.unsafe( + `DROP DATABASE IF EXISTS "${scratchDatabase}" WITH (FORCE)`, + ); + } finally { + await cleanup.end(); + } + } +} + +async function bootEmptyHub(args: { + scratchUrl: string; + signupMode: "open" | "closed"; +}): Promise<{ + baseUrl: string; + db: Awaited>["db"]; +}> { + const root = mkdtempSync(path.join(tmpdir(), "hub-signup-genesis-")); + const staticDir = path.join(root, "static"); + mkdirSync(staticDir, { recursive: true }); + writeFileSync(path.join(staticDir, "index.html"), "shell"); + mkdirSync(path.join(root, "data"), { recursive: true }); + + // The onboarding routes reach the hub over HTTP (`createHubAPI`), so + // the composed app must be served on a real port — bind first to + // learn the port, boot the hub against that base URL, then hand the + // server the app. + const server = Bun.serve({ + port: 0, + fetch: () => new Response("booting", { status: 503 }), + }); + const baseUrl = `http://localhost:${server.port}`; + const config: HubConfig = { + databaseUrl: args.scratchUrl, + baseUrl, + sessionSecret: "insecure-test-only-session-secret-0000", + hubDataDir: path.join(root, "data"), + hubStaticDir: staticDir, + defaultTenantSlug: "workbench", + signupRateLimit: { windowSeconds: 60, max: 5 }, + signInRateLimit: { windowSeconds: 60, max: 10 }, + socialProviders: {}, + signupMode: args.signupMode, + allowedEmailDomains: [], + allowPlaintextSecrets: true, + allowUnverifiedEmails: true, + sidecarProvisioners: [], + envProviderKeys: {}, + envProviderBaseUrls: {}, + envCredentialPlantAdmin: { + email: "boot-admin@example.com", + password: "password123", + orgSlug: "workbench", + }, + chatIdleReapMs: 30 * 60_000, + skipEnsureDefaultTenant: true, + }; + const hub = await createHub(config); + server.reload({ fetch: hub.app.fetch }); + closers.push(async () => { + server.stop(true); + await hub.close(); + rmSync(root, { recursive: true, force: true }); + }); + return { baseUrl, db: hub.db }; +} + +async function signUp( + baseUrl: string, + args: { name: string; email: string; password: string }, +): Promise { + const response = await fetch(`${baseUrl}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(args), + }); + expect(response.status).toBe(200); + const cookies = response.headers.getSetCookie(); + expect(cookies.length).toBeGreaterThan(0); + return cookies; +} + +async function provision( + baseUrl: string, + cookies: string[], + name?: string, +): Promise<{ + kind: string; + tenantId?: string; + tenantSlug?: string; + seeded?: boolean; +}> { + const response = await fetch(`${baseUrl}/api/onboarding/provision`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: cookies.join("; "), + }, + ...(name !== undefined ? { body: JSON.stringify({ name }) } : {}), + }); + expect(response.status).toBe(200); + return (await response.json()) as { + kind: string; + tenantId?: string; + tenantSlug?: string; + seeded?: boolean; + }; +} + +async function roleNamesFor( + db: Awaited>["db"], + args: { tenantId: string; email: string }, +): Promise { + const [userRow] = await db + .select({ id: userTable.id }) + .from(userTable) + .where(eq(userTable.email, args.email)) + .limit(1); + expect(userRow).toBeDefined(); + const userId: string = userRow?.id ?? ""; + expect(userId).not.toBe(""); + const rows = await db + .select({ roleName: role.name }) + .from(principal) + .innerJoin(principalRole, eq(principalRole.principalId, principal.id)) + .innerJoin(role, eq(role.id, principalRole.roleId)) + .where( + and( + eq(principal.tenantId, args.tenantId), + eq(principal.kind, "user"), + eq(principal.refId, userId), + eq(principal.status, "active"), + ), + ); + return rows.map((r) => r.roleName); +} + +describeIfDb("signup genesis (CL-7578)", () => { + test("a closed empty hub admits the first signup, which mints the root tenant as owner — no seed", async () => { + const scratchUrl = scratchUrlFor("closed"); + await withScratchDatabase(scratchUrl, async () => { + const { baseUrl, db } = await bootEmptyHub({ + scratchUrl, + signupMode: "closed", + }); + + // The empty-hub exception: signup is closed, but zero users and + // zero tenants means this caller is the genesis owner. + const cookies = await signUp(baseUrl, { + name: "Alice", + email: "alice@example.com", + password: "password123", + }); + + const probe = await provision(baseUrl, cookies); + expect(probe.kind).toBe("needs-onboarding"); + + const result = await provision(baseUrl, cookies, "Acme"); + expect(result.kind).toBe("provisioned"); + expect(result.tenantSlug).toBe("workbench"); + expect(result.seeded).toBe(false); + expect(typeof result.tenantId).toBe("string"); + const tenantId = result.tenantId; + + const [userCount] = await db.select().from(userTable); + expect(userCount).toBeDefined(); + const roots = await db + .select() + .from(tenant) + .where(isNull(tenant.parentId)); + expect(roots).toHaveLength(1); + expect(roots[0]?.slug).toBe("workbench"); + const rootId: string = roots[0]?.id ?? ""; + expect(rootId).not.toBe(""); + expect((await db.select().from(tenant)).length).toBe(1); + + expect( + await roleNamesFor(db, { + tenantId: rootId, + email: "alice@example.com", + }), + ).toEqual(["owner"]); + + // No seed side effects: the genesis tenant carries no workflow + // assets and no deployments. + const assets = await fetch( + `${baseUrl}/api/tenants/${tenantId}/assets?kind=workflow&inherited=false`, + { headers: { cookie: cookies.join("; ") } }, + ); + expect(await assets.json()).toEqual([]); + const deployments = await fetch( + `${baseUrl}/api/tenants/${tenantId}/workflows/deployments`, + { headers: { cookie: cookies.join("; ") } }, + ); + expect(await deployments.json()).toEqual([]); + }); + }); + + test("the second signup on an open hub joins the existing root as member, minting nothing", async () => { + const scratchUrl = scratchUrlFor("open"); + await withScratchDatabase(scratchUrl, async () => { + const { baseUrl, db } = await bootEmptyHub({ + scratchUrl, + signupMode: "open", + }); + + const alice = await signUp(baseUrl, { + name: "Alice", + email: "alice@example.com", + password: "password123", + }); + const genesis = await provision(baseUrl, alice, "Acme"); + expect(genesis.kind).toBe("provisioned"); + expect(genesis.tenantSlug).toBe("workbench"); + + const bob = await signUp(baseUrl, { + name: "Bob", + email: "bob@example.com", + password: "password123", + }); + // The join path needs no display name: a plain membership probe + // is enough, because the root already exists. + const joined = await provision(baseUrl, bob); + expect(joined.kind).toBe("provisioned"); + expect(joined.tenantSlug).toBe("workbench"); + expect(joined.seeded).toBe(false); + expect(joined.tenantId).toBe(genesis.tenantId); + + const tenants = await db.select().from(tenant); + expect(tenants).toHaveLength(1); + const tenantId: string = tenants[0]?.id ?? ""; + expect(tenantId).not.toBe(""); + expect((await db.select().from(userTable)).length).toBe(2); + + expect( + await roleNamesFor(db, { + tenantId, + email: "alice@example.com", + }), + ).toEqual(["owner"]); + expect( + await roleNamesFor(db, { + tenantId, + email: "bob@example.com", + }), + ).toEqual(["member"]); + }); + }); +}); diff --git a/apps/hub/test/tenant-create-guard.test.ts b/apps/hub/test/tenant-create-guard.test.ts index e19aaf258..3caf71114 100644 --- a/apps/hub/test/tenant-create-guard.test.ts +++ b/apps/hub/test/tenant-create-guard.test.ts @@ -45,6 +45,7 @@ function depsFor(args: { string, { tenancyCreation: "owners" | "owners-admins" | "none" } >; + tenants?: number; }): TenantCreateGuardDeps { const store = createInMemoryAccessPolicyStore(); for (const [tenantId, policy] of Object.entries( @@ -56,6 +57,7 @@ function depsFor(args: { const deps: TenantCreateGuardDeps = { store, resolveCallerRoleNames: args.resolveCallerRoleNames ?? noMembership, + countTenants: async () => args.tenants ?? 1, envSignupMode: args.envSignupMode ?? "closed", envAllowedDomains: [], allowUnverifiedEmails: false, @@ -217,6 +219,29 @@ describe("guardedHubApp — bypass shape B: arbitrary parentId under a tenant th }); describe("guardedHubApp — top-level and operator-tenant creation go through the signup gate", () => { + test("no parentId on an empty hub (genesis) is allowed even with signup closed", async () => { + const { app: nativeApp, created } = stubNativeApp(); + const deps = depsFor({ + user: { + id: "usr_first", + email: "first@example.com", + emailVerified: true, + }, + envSignupMode: "closed", + tenants: 0, + }); + const wrapped = guardedHubApp(nativeApp, deps); + + const response = await wrapped.request("/api/tenants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Acme", slug: "workbench" }), + }); + + expect(response.status).toBe(201); + expect(created).toHaveLength(1); + }); + test("no parentId, signup closed -> denied, fail closed", async () => { const { app: nativeApp, created } = stubNativeApp(); const deps = depsFor({ diff --git a/packages/onboarding/src/index.ts b/packages/onboarding/src/index.ts index eccc82070..1596f442a 100644 --- a/packages/onboarding/src/index.ts +++ b/packages/onboarding/src/index.ts @@ -3,6 +3,13 @@ export { provisionPersonalTenantIfNeeded, } from "./provision"; export type { ProvisionArgs, ProvisionResult } from "./provision"; +export { genesisOrJoinHubSignup, ProvisionError } from "./genesis"; +export type { + GenesisOrJoinArgs, + GenesisOrJoinResult, + HubSignupTenancy, + ProvisionErrorKind, +} from "./genesis"; export { completeCredentialSetup, findPersonalTenant, diff --git a/packages/onboarding/src/provision.ts b/packages/onboarding/src/provision.ts index a9d82c909..c54dfa45f 100644 --- a/packages/onboarding/src/provision.ts +++ b/packages/onboarding/src/provision.ts @@ -1,97 +1,42 @@ -// The first-login decision: a signed-in session with zero principals -// anywhere gets a personal bench, minted through the native tenant- -// creation route (never a product-owned tenant table of our own), -// parented under the operator tenant when one is configured, and -// seeded with the default workflow set when the hub carries a seed -// model credential. Every step reuses a native route or -// `@corbits/seeding`; nothing here re-implements tenant creation, -// grant planting, or workflow deployment. - -import { - AssetWithOriginResponse, - paginatedSchema, - PrincipalSummary, - TenantResponse, -} from "@intx/types"; +// The first-login decision. The first signup on an empty hub (zero +// tenants, at most the caller's own user row) mints the root tenant +// through the native tenant-creation route — its creator becomes that +// tenant's owner. Every later signup joins the existing root tenant as +// a plain member. This path never seeds workflows, tools, or grants: +// seeding is the credential-completion step's job, driven by the +// user's own credential, never by signup. + +import { AssetWithOriginResponse } from "@intx/types"; import { type } from "arktype"; import { DEFAULT_WORKFLOWS, - reconcileSeedGrants, - seedTenant, isCorbitsToolsRegistrySeeded, - type ModelSource, - type WorkflowPusher, isLiveDeploymentStatus, } from "@corbits/seeding"; import { parseAs, type ApiCall } from "@corbits/hub-api-client"; -import { reportError } from "@corbits/error-sink"; -import { - checkSignupGate, - type AccessPolicyStore, -} from "@workbench/access-policy"; +import type { AccessPolicyStore } from "@workbench/access-policy"; +import { genesisOrJoinHubSignup, type HubSignupTenancy } from "./genesis"; + +export { ProvisionError } from "./genesis"; +export type { ProvisionErrorKind } from "./genesis"; export type ProvisionResult = - | { - readonly kind: "existing-member"; - /** - * Present only when the caller owns the personal bench this hook - * itself provisions: `true` once every default workflow is - * deployed, `false` when it is still waiting on a working - * credential (the `bench_unseeded` condition the onboarding UI - * reads to keep the credential step open instead of declaring - * setup finished). Absent when membership belongs to some other - * tenant this hook does not own — its seed state is none of this - * hook's business. - * - * `seeded: true` here means every default workflow has an active - * deployment (`isFullySeeded`'s own check) — it is not, by itself, - * proof of a working inference credential. The onboarding UI must - * not hard-skip the credential step on this flag alone; see - * `tenantId` below. - */ - readonly seeded?: boolean; - /** - * Present under the same condition as `seeded`: the caller's own - * personal bench. Lets the onboarding UI independently confirm a - * working inference credential exists (a cheap credentials read) - * before trusting `seeded: true` enough to skip the credential - * step entirely. - */ - readonly tenantId?: string; - } + | { readonly kind: "existing-member" } | { readonly kind: "needs-onboarding" } | { + /** The caller now belongs to a tenant — as the owner of a + * freshly-minted root (genesis) or as a new member of the + * existing root (join). `seeded` is always `false`: signup + * provisions membership only; the credential step owns seeding. */ readonly kind: "provisioned"; readonly tenantId: string; readonly tenantSlug: string; - readonly seeded: boolean; - readonly seedSkipReason?: string; + readonly seeded: false; }; -/** - * A typed provisioning failure. `kind` lets the routes layer distinguish - * a retryable (transient) failure — sidecar down, race, network — from a - * permanent one — slug conflict with no principal, tenant created but - * membership missing — so the client can decide whether to retry without - * parsing a free-text message. - */ -export type ProvisionErrorKind = "transient" | "permanent"; - -export class ProvisionError extends Error { - readonly code: string; - readonly errorKind: ProvisionErrorKind; - constructor(code: string, message: string, errorKind: ProvisionErrorKind) { - super(message); - this.name = "ProvisionError"; - this.code = code; - this.errorKind = errorKind; - } -} - export type ProvisionArgs = { api: ApiCall; cookies: string[]; - hubUrl: string; userId: string; userEmail: string; /** better-auth is configured without `requireEmailVerification` — an @@ -99,23 +44,23 @@ export type ProvisionArgs = { * someone else. See `@workbench/access-policy`'s `evaluateSignupGate` * doc comment. */ userEmailVerified: boolean; - /** Display name for the personal bench. Required to mint: when omitted - * (shell membership probe), returns `needs-onboarding` and creates nothing. */ + /** Slug for the genesis tenant — the first tenant on an empty hub. + * Later signups join the existing root and never read this. */ + defaultTenantSlug: string; + /** Display name for the genesis tenant. Required to mint: when + * omitted (shell membership probe), returns `needs-onboarding` and + * creates nothing. */ displayName?: string; - operatorTenantId?: string; - seedModel?: ModelSource; - pushWorkflow: WorkflowPusher; + tenancy: HubSignupTenancy; log: (line: string) => void; - /** The closed-by-default access-policy gate. Absent means this hub - * runs with no access-policy package wired in at all — never a valid - * production shape, but some tests exercise provisioning in - * isolation from it. */ + /** The closed-by-default access-policy gate. Genesis-on-empty and + * join never consult it — those decisions belong to the sign-up + * route (empty-hub exception) and the tenant-create guard — but the + * seam stays so the wiring shape is unchanged. */ accessPolicy?: { store: AccessPolicyStore; envSignupMode: "open" | "closed"; envAllowedDomains: readonly string[]; - /** Dev/test-only opt-out of the `userEmailVerified` requirement — - * mirrors `ALLOW_PLAINTEXT_SECRETS`. Never set for a real deployment. */ allowUnverifiedEmails: boolean; }; }; @@ -123,7 +68,8 @@ export type ProvisionArgs = { /** A lowercase-kebab personal-bench slug, unique per user without a * coordinating registry: the local part of the email plus a short * fragment of the user's own id, which the platform already treats as - * unique. */ + * unique. Signup no longer mints personal benches, but the credential + * step still uses this to recognize a bench it provisioned itself. */ export function personalTenantSlug(email: string, userId: string): string { const local = email.split("@")[0] ?? email; const kebab = local @@ -137,23 +83,6 @@ export function personalTenantSlug(email: string, userId: string): string { return `${kebab || "bench"}-${suffix || "personal"}`; } -async function fetchPrincipals( - api: ApiCall, - cookies: string[], -): Promise<{ tenantId: string; tenantSlug: string; principalId: string }[]> { - const response = await api("GET", "/api/me/principals", undefined, cookies); - const summary = parseAs( - paginatedSchema(PrincipalSummary), - response.data, - "principals response", - ); - return summary.data.map((p) => ({ - tenantId: p.tenantId, - tenantSlug: p.tenantSlug, - principalId: p.principalId, - })); -} - const WorkflowDeploymentStatus = type({ definitionAssetId: "string", status: "string", @@ -243,215 +172,36 @@ export async function seededWorkflowStatus( } /** - * Runs the first-login hook: checks whether the caller already belongs - * to any tenant and, if not, provisions and seeds a personal bench for - * them. Safe to call on every sign-in — an existing member is a single - * read and nothing else. + * Runs the first-login hook by delegating the whole decision to + * `genesisOrJoinHubSignup` (see ./genesis.ts). Safe to call on every + * sign-in — an existing member is a single read and nothing else. */ export async function provisionPersonalTenantIfNeeded( args: ProvisionArgs, ): Promise { - const expectedSlug = personalTenantSlug(args.userEmail, args.userId); - const before = await fetchPrincipals(args.api, args.cookies); - if (before.length > 0) { - // A membership already exists. If it is not the personal bench this - // hook itself owns, there is nothing to recover — some other bench - // added this user, and that is none of this hook's business. If it - // is our own personal bench, an earlier call may have created the - // tenant and then failed before seeding it; re-seed rather than - // silently treating "created but never seeded" as done. - const own = before.find((p) => p.tenantSlug === expectedSlug); - // Not our personal bench: some other tenant added this user, which - // is none of this hook's business. Membership is decided here without - // depending on a seed credential — recovery of a half-provisioned - // bench must not hang forever just because no seed model is configured. - if (own === undefined) return { kind: "existing-member" }; - - // SEED_GRANTS can grow after this tenant was first seeded (CL-6465 - // added eval-run:*/read well after some tenants were provisioned) — - // reconcile it on every sign-in so a grant added later still reaches - // an already-seeded tenant, not only a brand-new one. Cheap and - // idempotent (`reconcileSeedGrants` skips any grant that already - // exists), and runs regardless of `fullySeeded` below, which tracks - // workflow deployments only and must never gate grant reconciliation. - // A failure here must never block sign-in for an otherwise healthy - // tenant, so it is reported rather than thrown. - try { - await reconcileSeedGrants( - args.api, - args.cookies, - own.tenantId, - own.principalId, - args.log, - ); - } catch (cause) { - reportError(cause, { - operation: "reconcile_seed_grants", - tenantId: own.tenantId, - }); - } - - const tenantResponse = await args.api( - "GET", - `/api/tenants/${own.tenantId}`, - undefined, - args.cookies, - ); - const ownTenant = parseAs( - TenantResponse, - tenantResponse.data, - "tenant response", - ); - const fullySeeded = await isFullySeeded( - args.api, - args.cookies, - own.tenantId, - ); - if (fullySeeded) - return { kind: "existing-member", seeded: true, tenantId: own.tenantId }; - - // Own bench exists but is not fully seeded. With a hub-owned seed - // model we can re-seed right here to recover. Without one there is - // nothing this hook can do — completing seeding from the caller's - // own credential is `completeCredentialSetup`'s job (the onboarding - // credential step), not this sign-in hook's — so we exit as an - // existing-member with `seeded: false`, the typed `bench_unseeded` - // condition the onboarding UI reads to keep the credential step open - // rather than declaring setup finished. - if (args.seedModel === undefined) { - args.log( - `personal bench ${own.tenantId} exists but is not fully seeded, and no seed model is configured; returning as existing-member without re-seeding`, - ); - return { kind: "existing-member", seeded: false, tenantId: own.tenantId }; - } - - const existingMemberSeedArgs = { - api: args.api, - cookies: args.cookies, - hubUrl: args.hubUrl, - tenant: { - tenantId: own.tenantId, - principalId: own.principalId, - domain: ownTenant.domain, - }, - model: args.seedModel, - pushWorkflow: args.pushWorkflow, - log: args.log, - workflows: DEFAULT_WORKFLOWS, - }; - await seedTenant(existingMemberSeedArgs); - return { kind: "existing-member", seeded: true, tenantId: own.tenantId }; - } - - // Creation requires an explicit display name from the onboarding - // naming step — a shell membership probe (no name) must not silently - // mint a personal bench. - if (args.displayName === undefined || args.displayName.trim().length === 0) { - return { kind: "needs-onboarding" }; - } - - if (args.accessPolicy !== undefined) { - const signupGateArgs = { - store: args.accessPolicy.store, - envSignupMode: args.accessPolicy.envSignupMode, - envAllowedDomains: args.accessPolicy.envAllowedDomains, - email: args.userEmail, - emailVerified: args.userEmailVerified, - allowUnverifiedEmails: args.accessPolicy.allowUnverifiedEmails, - }; - const gate = await checkSignupGate( - args.operatorTenantId !== undefined - ? { ...signupGateArgs, operatorTenantId: args.operatorTenantId } - : signupGateArgs, - ); - if (!gate.allowed) { - throw new ProvisionError( - "signup_not_allowed", - `self-serve signup is not allowed for ${args.userEmail} (${gate.reason})`, - "permanent", - ); - } - } - - const tenantCreateBody: { name: string; slug: string; parentId?: string } = { - name: args.displayName.trim(), - slug: expectedSlug, - }; - if (args.operatorTenantId !== undefined) - tenantCreateBody.parentId = args.operatorTenantId; - - const created = await args.api( - "POST", - "/api/tenants", - tenantCreateBody, - args.cookies, - ); - if (created.status === 409) { - // Lost a race: another concurrent first-login call for this same - // user already created the (deterministically-slugged) personal - // bench between our own "zero principals" read and this create. The - // loser recognizes "someone already provisioned me" rather than - // surfacing the native route's slug conflict as a failure. - const afterRace = await fetchPrincipals(args.api, args.cookies); - if (afterRace.length > 0) return { kind: "existing-member" }; - throw new ProvisionError( - "slug_conflict_no_principal", - `first-login provisioning hit a slug conflict creating a personal bench, but the caller still has no principal anywhere: ${JSON.stringify(created.data)}`, - "permanent", - ); - } - if (created.status !== 201) { - throw new ProvisionError( - "tenant_create_failed", - `first-login provisioning could not create a personal bench (status ${created.status}): ${JSON.stringify(created.data)}`, - created.status >= 500 ? "transient" : "permanent", - ); - } - const tenant = parseAs(TenantResponse, created.data, "tenant response"); - - const after = await fetchPrincipals(args.api, args.cookies); - const membership = after.find((p) => p.tenantId === tenant.id); - if (membership === undefined) { - throw new ProvisionError( - "tenant_created_no_membership", - `personal bench ${tenant.id} was created but the caller has no principal in it`, - "transient", - ); - } - - if (!args.seedModel) { - const seedSkipReason = - "no hub-owned seed model credential is configured (ANTHROPIC_API_KEY); the bench was provisioned without the default workflow set"; - args.log(`bench ${tenant.slug}: ${seedSkipReason}`); + const result = await genesisOrJoinHubSignup({ + api: args.api, + cookies: args.cookies, + userId: args.userId, + userEmail: args.userEmail, + userEmailVerified: args.userEmailVerified, + defaultTenantSlug: args.defaultTenantSlug, + tenancy: args.tenancy, + log: args.log, + ...(args.accessPolicy !== undefined + ? { accessPolicy: args.accessPolicy } + : {}), + ...(args.displayName !== undefined + ? { displayName: args.displayName } + : {}), + }); + if (result.kind === "genesis" || result.kind === "joined") { return { kind: "provisioned", - tenantId: tenant.id, - tenantSlug: tenant.slug, + tenantId: result.tenantId, + tenantSlug: result.tenantSlug, seeded: false, - seedSkipReason, }; } - - const provisionedSeedArgs = { - api: args.api, - cookies: args.cookies, - hubUrl: args.hubUrl, - tenant: { - tenantId: tenant.id, - principalId: membership.principalId, - domain: tenant.domain, - }, - model: args.seedModel, - pushWorkflow: args.pushWorkflow, - log: args.log, - workflows: DEFAULT_WORKFLOWS, - }; - await seedTenant(provisionedSeedArgs); - - return { - kind: "provisioned", - tenantId: tenant.id, - tenantSlug: tenant.slug, - seeded: true, - }; + return result; } diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 500b86da2..71d62ada3 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -20,7 +20,6 @@ import { import { inferenceCredentialName, SETUP_AGENT_ASSET_NAME, - type ModelSource, type WorkflowPusher, } from "@corbits/seeding"; import { @@ -45,6 +44,8 @@ import { seededWorkflowStatus, } from "./provision"; +import type { HubSignupTenancy } from "./genesis"; + import { ensureSeeded, findPersonalTenant, @@ -137,8 +138,13 @@ const ProvisionBody = type({ export type CreateOnboardingRoutesDeps = { hubUrl: string; - operatorTenantId?: string; - seedModel?: ModelSource; + /** Slug for the genesis tenant the first signup on an empty hub + * mints; later signups join the existing root and never read it. */ + defaultTenantSlug: string; + /** Hub tenancy reads/writes for the genesis-or-join decision — see + * ./genesis.ts's `HubSignupTenancy`. Production wiring is + * `createHubSignupTenancy` in apps/hub/src/signup-tenancy.ts. */ + tenancy: HubSignupTenancy; pushWorkflow: WorkflowPusher; log: (line: string) => void; /** Error-level sibling of `log`: every server-side failure path in @@ -442,17 +448,13 @@ export function createOnboardingRoutes( >[0] = { api, cookies, - hubUrl: deps.hubUrl, userId: user.id, userEmail: user.email, userEmailVerified: user.emailVerified, - pushWorkflow: deps.pushWorkflow, + defaultTenantSlug: deps.defaultTenantSlug, + tenancy: deps.tenancy, log: deps.log, }; - if (deps.operatorTenantId !== undefined) - provisionArgs.operatorTenantId = deps.operatorTenantId; - if (deps.seedModel !== undefined) - provisionArgs.seedModel = deps.seedModel; if (body?.name !== undefined) provisionArgs.displayName = body.name; if (deps.accessPolicy !== undefined) provisionArgs.accessPolicy = deps.accessPolicy; diff --git a/packages/onboarding/test/complete-setup-routes.test.ts b/packages/onboarding/test/complete-setup-routes.test.ts index 8d6f9c5a0..1e2d9fae0 100644 --- a/packages/onboarding/test/complete-setup-routes.test.ts +++ b/packages/onboarding/test/complete-setup-routes.test.ts @@ -40,6 +40,17 @@ function testCipher(): CredentialCipher { return createEnvKeyCredentialCipher(TEST_KEY); } +// These tests never exercise the genesis-or-join join path — the stub +// satisfies the required tenancy wiring without standing up a DB. +const emptyHubTenancy = { + countUsers: async () => 0, + countTenants: async () => 0, + findRootTenant: async () => null, + addActiveMember: async () => { + throw new Error("these suites never exercise the join path"); + }, +}; + const TENANT_ID = "ten_1"; const PRINCIPAL_ID = "prn_1"; const TENANT_SLUG = "user-1-user1"; @@ -113,6 +124,8 @@ describe("POST /complete-setup", () => { app.route( "/api/onboarding", createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: "https://bench.example.com", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -139,6 +152,8 @@ describe("POST /complete-setup", () => { try { const app = mountAuthenticated( createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -200,6 +215,8 @@ describe("POST /complete-setup", () => { try { const app = mountAuthenticated( createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -253,6 +270,8 @@ describe("POST /complete-setup", () => { try { const app = mountAuthenticated( createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -303,6 +322,8 @@ describe("POST /complete-setup", () => { try { const app = mountAuthenticated( createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -338,6 +359,8 @@ describe("POST /complete-setup", () => { let ensureSeededCalls = 0; const app = mountAuthenticated( createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -403,6 +426,8 @@ describe("POST /complete-setup", () => { let wakes = 0; const app = mountAuthenticated( createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -466,6 +491,8 @@ describe("POST /complete-setup", () => { try { const app = mountAuthenticated( createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -514,6 +541,8 @@ describe("POST /complete-setup", () => { await withPendingSeed(pendingSeedStore, { ttlMs: -1 }); const app = mountAuthenticated( createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -555,6 +584,8 @@ describe("POST /complete-setup", () => { let ensureSeededCalls = 0; const app = mountAuthenticated( createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -643,6 +674,8 @@ describe("POST /complete-setup", () => { let ensureSeededCalls = 0; const app = mountAuthenticated( createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -729,6 +762,8 @@ describe("POST /complete-setup", () => { try { const app = mountAuthenticated( createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, diff --git a/packages/onboarding/test/connect-deploys-nothing.test.ts b/packages/onboarding/test/connect-deploys-nothing.test.ts index 05b2ba723..1562b87e4 100644 --- a/packages/onboarding/test/connect-deploys-nothing.test.ts +++ b/packages/onboarding/test/connect-deploys-nothing.test.ts @@ -111,6 +111,17 @@ function routeDeps(args: { }) { return { hubUrl: args.hubUrl, + // This suite never exercises the genesis-or-join join path; the + // stub satisfies the required tenancy wiring without a DB. + tenancy: { + countUsers: async () => 0, + countTenants: async () => 0, + findRootTenant: async () => null, + addActiveMember: async () => { + throw new Error("this suite never exercises the join path"); + }, + }, + defaultTenantSlug: "workbench", pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40), diff --git a/packages/onboarding/test/huggingface-connect-routes.test.ts b/packages/onboarding/test/huggingface-connect-routes.test.ts index 929a7e4f9..62ad95921 100644 --- a/packages/onboarding/test/huggingface-connect-routes.test.ts +++ b/packages/onboarding/test/huggingface-connect-routes.test.ts @@ -199,6 +199,17 @@ function connectRoutes( ): Hono { const deps: CreateOnboardingRoutesDeps = { hubUrl: overrides.hubUrl ?? "https://bench.example.com", + // This suite never exercises the genesis-or-join join path; the + // stub satisfies the required tenancy wiring without a DB. + tenancy: { + countUsers: async () => 0, + countTenants: async () => 0, + findRootTenant: async () => null, + addActiveMember: async () => { + throw new Error("this suite never exercises the join path"); + }, + }, + defaultTenantSlug: "workbench", pushWorkflow: overrides.pushWorkflow ?? (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), diff --git a/packages/onboarding/test/openrouter-connect-routes.test.ts b/packages/onboarding/test/openrouter-connect-routes.test.ts index c3653814b..33ed954c4 100644 --- a/packages/onboarding/test/openrouter-connect-routes.test.ts +++ b/packages/onboarding/test/openrouter-connect-routes.test.ts @@ -209,6 +209,17 @@ function connectRoutes( ): Hono { const deps: CreateOnboardingRoutesDeps = { hubUrl: overrides.hubUrl ?? "https://bench.example.com", + // This suite never exercises the genesis-or-join join path; the + // stub satisfies the required tenancy wiring without a DB. + tenancy: { + countUsers: async () => 0, + countTenants: async () => 0, + findRootTenant: async () => null, + addActiveMember: async () => { + throw new Error("this suite never exercises the join path"); + }, + }, + defaultTenantSlug: "workbench", pushWorkflow: overrides.pushWorkflow ?? (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index aff387d48..e3e3828ee 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -1,34 +1,18 @@ import { describe, expect, test } from "bun:test"; -import { - DEFAULT_WORKFLOWS, - SEED_GRANTS, - SETUP_AGENT_ASSET_NAME, -} from "@corbits/seeding"; -import type { WorkflowPusher } from "@corbits/seeding"; +import { DEFAULT_WORKFLOWS } from "@corbits/seeding"; import type { ApiCall } from "@corbits/hub-api-client"; +import type { HubSignupTenancy } from "../src/genesis"; import { isFullySeeded, personalTenantSlug, provisionPersonalTenantIfNeeded, - seededWorkflowStatus, } from "../src/provision"; const TENANT_ID = "ten_new"; const PRINCIPAL_ID = "prn_new"; -const TENANT_SLUG = "alice-user1"; -const DEPLOYMENT_ID = "dep_1"; - -const MODEL = { - provider: "anthropic", - model: "claude-sonnet-4-5", - baseURL: "https://api.anthropic.com", - apiKey: "sk-test", -}; +const MEMBER_PRINCIPAL_ID = "prn_member"; +const TENANT_SLUG = "workbench"; -const noopPush: WorkflowPusher = async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), -}); const TOOLS_ASSET_ID = "ast_corbits_tools"; const SEEDED_MEMORY_TARBALL = { filename: "corbits-memory-tools-0.0.4.tgz", @@ -87,260 +71,66 @@ function collector() { return { lines, log: (line: string) => lines.push(line) }; } -// `ensureDeployment` resolves a real (non-noop-pinned) workflow's deploy -// source from the tenant's own catalog offerings (CL-7461); every test -// that seeds a real default workflow needs at least one listable, since -// none of these tests seed a catalog of its own. -function catalogOfferingsResponse( - method: string, - path: string, - tenantId: string, -): { status: number; data: unknown; cookies: string[] } | undefined { - if ( - method !== "GET" || - path !== `/api/tenants/${tenantId}/catalog/resolved-offerings` - ) - return undefined; +function tenancy(state: { + users?: number; + tenants?: number; + root?: { id: string; slug: string } | null; + joins?: { tenantId: string; userId: string; roleName: string }[]; +}): HubSignupTenancy { + const joins = state.joins ?? []; + return { + countUsers: async () => state.users ?? 0, + countTenants: async () => state.tenants ?? 0, + findRootTenant: async () => (state.root === undefined ? null : state.root), + addActiveMember: async (args) => { + joins.push(args); + return { principalId: MEMBER_PRINCIPAL_ID }; + }, + }; +} + +function principalsResponse( + rows: { + principalId: string; + tenantId: string; + tenantSlug: string; + }[], +) { return { status: 200, data: { - offerings: [ - { - id: "off_1", - priority: 0, - modelId: "mdl_1", - providerId: "mpr_1", - origin: { tenantId, direct: true }, - }, - ], + data: rows.map((row) => ({ + principalId: row.principalId, + tenantId: row.tenantId, + tenantName: "Workbench", + tenantSlug: row.tenantSlug, + kind: "user", + status: "active", + roles: [], + })), + nextCursor: null, }, cookies: [], }; } -function firstLoginSeedHub(args: { expectedParentId?: string }) { - let principalsCalls = 0; - const startedRuns: string[] = []; - const tarballPuts: string[] = []; - const api: ApiCall = async (method, path, body) => { - const registry = corbitsToolsRegistryResponse( - method, - path, - TENANT_ID, - "missing", - ); - if (registry !== undefined) return registry; - if (path.includes("/tarballs/")) { - tarballPuts.push(`${method} ${path}`); - throw new Error(`signup must not pack: ${method} ${path}`); - } - if (method === "GET" && path === "/api/me/principals") { - principalsCalls += 1; - if (principalsCalls === 1) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "alice's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "POST" && path === "/api/tenants") { - const parsed = body as { - parentId?: string; - slug: string; - name: string; - }; - expect(parsed.parentId).toBe(args.expectedParentId); - expect(parsed.name).toBe("Alice's Lab"); - return { - status: 201, - data: { - id: TENANT_ID, - name: parsed.name, - slug: parsed.slug, - domain: `${parsed.slug}.localhost`, - ...(args.expectedParentId !== undefined - ? { parentId: args.expectedParentId } - : {}), - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) - ) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - return { status: 201, data: {}, cookies: [] }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { - return { - status: 201, - data: { - id: "ast_1", - tenantId: TENANT_ID, - kind: "workflow", - name: "echo", - displayName: null, - creatorPrincipalId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/git-tokens`) { - return { - status: 201, - data: { id: "tok_1", secret: "s3cret" }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) - ) { - return { status: 404, data: {}, cookies: [] }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) { - return { status: 201, data: {}, cookies: [] }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { status: 200, data: [], cookies: [] }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` - ) { - return { - status: 200, - data: { - offerings: [ - { - id: "off_1", - priority: 0, - modelId: "mdl_1", - providerId: "mpr_1", - origin: { tenantId: TENANT_ID, direct: true }, - }, - ], - }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/workflows/definitions`) - ) { - return { - status: 200, - data: { - data: [ - { - id: "wfd_digest", - tenantId: TENANT_ID, - name: "workbench-digest", - currentVersion: "1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if ( - method === "PUT" && - path === `/api/tenants/${TENANT_ID}/agent-definitions/wfd_digest/status` - ) { - return { - status: 200, - data: { id: "wfd_digest", status: "stopped" }, - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { status: 200, data: [], cookies: [] }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { - status: 201, - data: { - id: DEPLOYMENT_ID, - tenantId: TENANT_ID, - definitionAssetId: "ast_1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/${DEPLOYMENT_ID}/runs` - ) { - return { - status: 200, - data: { runIds: [...startedRuns] }, - cookies: [], - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/${DEPLOYMENT_ID}/mail` - ) { - const runId = `run_${startedRuns.length + 1}`; - startedRuns.push(runId); - return { - status: 202, - data: { - runId: DEPLOYMENT_ID, - address: "echo@x", - messageId: `m${startedRuns.length}`, - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); +function argsFor(partial: { + api: ApiCall; + tenancy: HubSignupTenancy; + displayName?: string; +}): Parameters[0] { + const args: Parameters[0] = { + api: partial.api, + cookies: ["session=abc"], + userId: "user_1", + userEmail: "alice@example.com", + userEmailVerified: true, + defaultTenantSlug: TENANT_SLUG, + tenancy: partial.tenancy, + log: collector().log, }; - return { api, tarballPuts }; + if (partial.displayName !== undefined) args.displayName = partial.displayName; + return args; } describe("personalTenantSlug", () => { @@ -360,24 +150,13 @@ describe("provisionPersonalTenantIfNeeded", () => { let tenantCreateCalls = 0; const api: ApiCall = async (method, path) => { if (method === "GET" && path === "/api/me/principals") { - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: "ten_existing", - tenantName: "Existing", - tenantSlug: "existing", - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, + return principalsResponse([ + { + principalId: PRINCIPAL_ID, + tenantId: "ten_existing", + tenantSlug: "existing", }, - cookies: [], - }; + ]); } if (method === "POST" && path === "/api/tenants") { tenantCreateCalls += 1; @@ -386,160 +165,60 @@ describe("provisionPersonalTenantIfNeeded", () => { throw new Error(`unexpected call: ${method} ${path}`); }; - const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - pushWorkflow: noopPush, - log: collector().log, - }); + const result = await provisionPersonalTenantIfNeeded( + argsFor({ + api, + tenancy: tenancy({ users: 2, tenants: 1 }), + }), + ); expect(result).toEqual({ kind: "existing-member" }); expect(tenantCreateCalls).toBe(0); }); - test("losing a concurrent-provisioning race returns the winner's membership instead of erroring", async () => { - let principalsCalls = 0; + test("zero principals without a display name: needs-onboarding, nothing created", async () => { const api: ApiCall = async (method, path) => { if (method === "GET" && path === "/api/me/principals") { - principalsCalls += 1; - if (principalsCalls === 1) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - // The race's winner already created the bench by the time this - // caller re-checks after its own create lost with a 409. - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "alice's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "POST" && path === "/api/tenants") { - return { - status: 409, - data: { error: { code: "conflict", message: "Slug already taken" } }, - cookies: [], - }; + return principalsResponse([]); } throw new Error(`unexpected call: ${method} ${path}`); }; - const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - displayName: "Alice's Lab", - pushWorkflow: noopPush, - log: collector().log, - }); - - expect(result).toEqual({ kind: "existing-member" }); - }); - - test("a slug conflict that still leaves the caller benchless is a real failure", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - if (method === "POST" && path === "/api/tenants") { - return { - status: 409, - data: { error: { code: "conflict", message: "Slug already taken" } }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; + const result = await provisionPersonalTenantIfNeeded( + argsFor({ api, tenancy: tenancy({ users: 1, tenants: 0 }) }), + ); - await expect( - provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - displayName: "Alice's Lab", - pushWorkflow: noopPush, - log: collector().log, - }), - ).rejects.toThrow(/slug conflict/); + expect(result).toEqual({ kind: "needs-onboarding" }); }); - test("zero principals with no seed model: provisions the bench and reports the seed skip loudly", async () => { - let principalsCalls = 0; + test("genesis on an empty hub: provisions the root tenant, seeds nothing", async () => { + const bodies: unknown[] = []; const { lines, log } = collector(); + let principalsCalls = 0; const api: ApiCall = async (method, path, body) => { + if (path.includes("/assets") || path.includes("/workflows")) { + throw new Error(`signup must not seed: ${method} ${path}`); + } if (method === "GET" && path === "/api/me/principals") { principalsCalls += 1; - if (principalsCalls === 1) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "alice's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ], - nextCursor: null, + if (principalsCalls === 1) return principalsResponse([]); + return principalsResponse([ + { + principalId: PRINCIPAL_ID, + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, }, - cookies: [], - }; + ]); } if (method === "POST" && path === "/api/tenants") { - const parsed = body as { - parentId?: string; - slug: string; - name: string; - }; - expect(parsed.parentId).toBeUndefined(); - expect(parsed.name).toBe("Alice's Lab"); + bodies.push(body); return { status: 201, data: { id: TENANT_ID, - name: parsed.name, - slug: parsed.slug, - domain: `${parsed.slug}.localhost`, + name: "Alice's Lab", + slug: TENANT_SLUG, + domain: `${TENANT_SLUG}.localhost`, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, @@ -550,1351 +229,170 @@ describe("provisionPersonalTenantIfNeeded", () => { }; const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, + ...argsFor({ api, tenancy: tenancy({ users: 1, tenants: 0 }) }), displayName: "Alice's Lab", - pushWorkflow: noopPush, log, }); - expect(result.kind).toBe("provisioned"); - if (result.kind !== "provisioned") throw new Error("unreachable"); - expect(result.seeded).toBe(false); - expect(result.seedSkipReason).toContain("ANTHROPIC_API_KEY"); - expect(lines.some((line) => line.includes("ANTHROPIC_API_KEY"))).toBe(true); + expect(result).toEqual({ + kind: "provisioned", + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + seeded: false, + }); + expect(bodies).toEqual([{ name: "Alice's Lab", slug: TENANT_SLUG }]); + expect(lines.some((line) => line.includes("genesis"))).toBe(true); }); - test("zero principals without a display name: returns needs-onboarding and creates nothing", async () => { - const lines: string[] = []; - const log = (line: string) => lines.push(line); - let tenantsPosted = 0; + test("an occupied hub: the caller joins the root as member, never mints", async () => { + const joins: { tenantId: string; userId: string; roleName: string }[] = []; const api: ApiCall = async (method, path) => { if (method === "GET" && path === "/api/me/principals") { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; + return principalsResponse([]); } if (method === "POST" && path === "/api/tenants") { - tenantsPosted += 1; - throw new Error("must not create without a display name"); + throw new Error("join must never mint a tenant"); } throw new Error(`unexpected call: ${method} ${path}`); }; - const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - pushWorkflow: noopPush, - log, - }); - - expect(result).toEqual({ kind: "needs-onboarding" }); - expect(tenantsPosted).toBe(0); - }); - - test("zero principals with a seed model configured: provisions under the operator tenant and seeds the default workflow", async () => { - const { api, tarballPuts } = firstLoginSeedHub({ - expectedParentId: "ten_operator", - }); - const { log } = collector(); - const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - displayName: "Alice's Lab", - operatorTenantId: "ten_operator", - seedModel: MODEL, - pushWorkflow: noopPush, - log, - }); + const result = await provisionPersonalTenantIfNeeded( + argsFor({ + api, + tenancy: tenancy({ + users: 2, + tenants: 1, + root: { id: TENANT_ID, slug: TENANT_SLUG }, + joins, + }), + }), + ); expect(result).toEqual({ kind: "provisioned", tenantId: TENANT_ID, tenantSlug: TENANT_SLUG, - seeded: true, + seeded: false, }); - expect(tarballPuts).toEqual([]); + expect(joins).toEqual([ + { tenantId: TENANT_ID, userId: "user_1", roleName: "member" }, + ]); }); - test("an unparented personal root bench does not publish corbits-tools; provision only deploys workflows", async () => { - const { api, tarballPuts } = firstLoginSeedHub({}); - const { log } = collector(); - const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - displayName: "Alice's Lab", - seedModel: MODEL, - pushWorkflow: noopPush, - log, - }); + test("a slug conflict that still leaves the caller benchless is a real failure", async () => { + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse([]); + } + if (method === "POST" && path === "/api/tenants") { + return { + status: 409, + data: { error: { code: "conflict", message: "Slug already taken" } }, + cookies: [], + }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; - expect(result).toEqual({ - kind: "provisioned", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - seeded: true, - }); - expect(tarballPuts).toEqual([]); + await expect( + provisionPersonalTenantIfNeeded( + argsFor({ + api, + tenancy: tenancy({ users: 1, tenants: 0 }), + displayName: "Alice's Lab", + }), + ), + ).rejects.toThrow(/slug conflict/); }); +}); - test("a retry after tenant creation succeeded but seeding failed re-seeds instead of reporting a plain existing member", async () => { - let assetCreateAttempts = 0; - const startedRuns: string[] = []; - let tenantCreated = false; - - const membership = () => ({ - status: 200, - data: { - data: tenantCreated - ? [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "alice's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ] - : [], - nextCursor: null, - }, - cookies: [], - }); - - const api: ApiCall = async (method, path, body) => { +describe("isFullySeeded", () => { + test("isFullySeeded is false when corbits-tools exists but has no tarballs", async () => { + const api: ApiCall = async (method, path) => { const registry = corbitsToolsRegistryResponse( method, path, TENANT_ID, - "missing", + [], ); if (registry !== undefined) return registry; if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { return { status: 200, - data: { - offerings: [ - { - id: "off_1", - priority: 0, - modelId: "mdl_1", - providerId: "mpr_1", - origin: { tenantId: TENANT_ID, direct: true }, - }, - ], - }, - cookies: [], - }; - } - if (method === "GET" && path === "/api/me/principals") { - return membership(); - } - if (method === "POST" && path === "/api/tenants") { - tenantCreated = true; - const parsed = body as { slug: string }; - return { - status: 201, - data: { - id: TENANT_ID, - name: "alice's workbench", - slug: parsed.slug, - domain: `${parsed.slug}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return { - status: 200, - data: { - id: TENANT_ID, - name: "alice's workbench", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, + data: DEFAULT_WORKFLOWS.map((workflow, index) => ({ + id: `ast_${index}`, + tenantId: TENANT_ID, + kind: "workflow", + name: workflow.assetName, + displayName: workflow.displayName, + creatorPrincipalId: PRINCIPAL_ID, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - }, + origin: { tenantId: TENANT_ID, direct: true }, + })), cookies: [], }; } if ( method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) + path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { return { status: 200, - data: { data: [], nextCursor: null }, + data: DEFAULT_WORKFLOWS.map((_workflow, index) => ({ + definitionAssetId: `ast_${index}`, + status: "deployed", + })), cookies: [], }; } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - return { status: 201, data: {}, cookies: [] }; - } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + expect(await isFullySeeded(api, ["session=abc"], TENANT_ID)).toBe(false); + }); + + test("isFullySeeded is true when workflows are live and corbits-tools carries memory-tools", async () => { + const api: ApiCall = async (method, path) => { + const registry = corbitsToolsRegistryResponse(method, path, TENANT_ID, [ + SEEDED_MEMORY_TARBALL, + ]); + if (registry !== undefined) return registry; if ( method === "GET" && path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { - return { status: 200, data: [], cookies: [] }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { - assetCreateAttempts += 1; - if (assetCreateAttempts === 1) { - // The first attempt's seeding fails right here, after the - // tenant itself was already created above. - return { - status: 500, - data: { error: "asset service unavailable" }, - cookies: [], - }; - } return { - status: 201, - data: { - id: "ast_1", + status: 200, + data: DEFAULT_WORKFLOWS.map((workflow, index) => ({ + id: `ast_${index}`, tenantId: TENANT_ID, kind: "workflow", - name: "echo", - displayName: null, - creatorPrincipalId: null, + name: workflow.assetName, + displayName: workflow.displayName, + creatorPrincipalId: PRINCIPAL_ID, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - }, + origin: { tenantId: TENANT_ID, direct: true }, + })), cookies: [], }; } if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/git-tokens` + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { return { - status: 201, - data: { id: "tok_1", secret: "s3cret" }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) - ) { - return { status: 404, data: {}, cookies: [] }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) { - return { status: 201, data: {}, cookies: [] }; - } - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { status: 200, data: [], cookies: [] }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/workflows/definitions`) - ) { - return { - status: 200, - data: { - data: [ - { - id: "wfd_digest", - tenantId: TENANT_ID, - name: "workbench-digest", - currentVersion: "1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if ( - method === "PUT" && - path === `/api/tenants/${TENANT_ID}/agent-definitions/wfd_digest/status` - ) { - return { - status: 200, - data: { id: "wfd_digest", status: "stopped" }, - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { status: 200, data: [], cookies: [] }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { - status: 201, - data: { - id: DEPLOYMENT_ID, - tenantId: TENANT_ID, - definitionAssetId: "ast_1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/${DEPLOYMENT_ID}/runs` - ) { - return { - status: 200, - data: { runIds: [...startedRuns] }, - cookies: [], - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/${DEPLOYMENT_ID}/mail` - ) { - const runId = `run_${startedRuns.length + 1}`; - startedRuns.push(runId); - return { - status: 202, - data: { - runId: DEPLOYMENT_ID, - address: "echo@x", - messageId: `m${startedRuns.length}`, - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const firstAttempt = provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - displayName: "Alice's Lab", - seedModel: MODEL, - pushWorkflow: noopPush, - log: collector().log, - }); - await expect(firstAttempt).rejects.toThrow(/asset service unavailable/); - expect(tenantCreated).toBe(true); - - const { log } = collector(); - const retry = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - seedModel: MODEL, - pushWorkflow: noopPush, - log, - }); - - expect(retry).toEqual({ - kind: "existing-member", - seeded: true, - tenantId: "ten_new", - }); - // Attempt 1 fails creating the (only) default workflow's asset. The - // retry re-runs from scratch: one create call for the default set - // (assistant, CL-7074), on top of the one failed attempt. - expect(assetCreateAttempts).toBe(2); - }); - - test("a fully seeded personal bench reports existing-member with seeded: true, and backfills a grant added to SEED_GRANTS after it was provisioned", async () => { - // Every default workflow already has an active deployment — nothing - // for this hook to do on the workflow side, but the caller must be - // able to tell "already seeded" apart from "seeded and unseeded look - // identical," which is exactly the ambiguity that hid the - // bench_unseeded defect. This tenant also stands in for CL-6475: it - // was provisioned before eval-run:*/read existed (CL-6465 added it), - // so every grant except that one is already planted. The "fully - // seeded" workflow check must never short-circuit past reconciling - // it in. - const missingGrant = { resource: "eval-run:*", action: "read" }; - const alreadyGranted = SEED_GRANTS.filter( - (g) => - !( - g.resource === missingGrant.resource && - g.action === missingGrant.action - ), - ); - const grantsPosted: { resource: string; action: string }[] = []; - const api: ApiCall = async (method, path, body) => { - const registry = corbitsToolsRegistryResponse(method, path, TENANT_ID, [ - SEEDED_MEMORY_TARBALL, - ]); - if (registry !== undefined) return registry; - const offerings = catalogOfferingsResponse(method, path, TENANT_ID); - if (offerings !== undefined) return offerings; - if (method === "GET" && path === "/api/me/principals") { - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "alice's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) - ) { - const resource = new URL(`http://x${path}`).searchParams.get( - "resource", - ); - const rows = alreadyGranted - .filter((g) => g.resource === resource) - .map((g, index) => ({ - id: `grt_${resource}_${index}`, - tenantId: TENANT_ID, - principalId: PRINCIPAL_ID, - resource: g.resource, - action: g.action, - effect: "allow", - origin: "creator", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - })); - return { - status: 200, - data: { data: rows, nextCursor: null }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - const grant = body as { resource: string; action: string }; - grantsPosted.push({ resource: grant.resource, action: grant.action }); - return { status: 201, data: {}, cookies: [] }; - } - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { - status: 200, - data: DEFAULT_WORKFLOWS.map((workflow, index) => ({ - id: `ast_${index}`, - tenantId: TENANT_ID, - kind: "workflow", - name: workflow.assetName, - displayName: workflow.displayName, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId: TENANT_ID, direct: true }, - })), - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { - status: 200, - data: DEFAULT_WORKFLOWS.map((_workflow, index) => ({ - definitionAssetId: `ast_${index}`, - status: "deployed", - })), - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return { - status: 200, - data: { - id: TENANT_ID, - name: "alice's workbench", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - // No seedModel needed: nothing left to seed on the workflow side. - pushWorkflow: noopPush, - log: collector().log, - }); - - expect(result).toEqual({ - kind: "existing-member", - seeded: true, - tenantId: "ten_new", - }); - // Exactly the one grant this tenant was missing — no more, no less. - expect(grantsPosted).toEqual([missingGrant]); - }); - - test("a grant-reconcile failure is reported, not thrown -- sign-in still succeeds for a fully seeded bench", async () => { - const api: ApiCall = async (method, path) => { - const registry = corbitsToolsRegistryResponse(method, path, TENANT_ID, [ - SEEDED_MEMORY_TARBALL, - ]); - if (registry !== undefined) return registry; - const offerings = catalogOfferingsResponse(method, path, TENANT_ID); - if (offerings !== undefined) return offerings; - if (method === "GET" && path === "/api/me/principals") { - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "alice's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) - ) { - // A transient hub failure while reconciling grants. - return { - status: 500, - data: { error: "grants unavailable" }, - cookies: [], - }; - } - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { - status: 200, - data: DEFAULT_WORKFLOWS.map((workflow, index) => ({ - id: `ast_${index}`, - tenantId: TENANT_ID, - kind: "workflow", - name: workflow.assetName, - displayName: workflow.displayName, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId: TENANT_ID, direct: true }, - })), - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { - status: 200, - data: DEFAULT_WORKFLOWS.map((_workflow, index) => ({ - definitionAssetId: `ast_${index}`, - status: "deployed", - })), - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return { - status: 200, - data: { - id: TENANT_ID, - name: "alice's workbench", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - pushWorkflow: noopPush, - log: collector().log, - }); - - expect(result).toEqual({ - kind: "existing-member", - seeded: true, - tenantId: "ten_new", - }); - }); - - test("half-provisioned personal bench without a seed model returns existing-member (not stuck)", async () => { - // Without ANTHROPIC_API_KEY the server has no seed model. Membership of a - // personal bench must still resolve — recovery of "I have a bench" must - // not depend on a seed credential that may never exist. Seeding itself is - // skipped (nothing to seed with); the user is not stranded in a loop. - let assetListCalls = 0; - const api: ApiCall = async (method, path) => { - const registry = corbitsToolsRegistryResponse( - method, - path, - TENANT_ID, - "missing", - ); - if (registry !== undefined) return registry; - const offerings = catalogOfferingsResponse(method, path, TENANT_ID); - if (offerings !== undefined) return offerings; - if (method === "GET" && path === "/api/me/principals") { - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "alice's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) - ) { - // Grant reconciliation runs regardless of seed-model - // availability -- it needs no model, only the hub API. - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - return { status: 201, data: {}, cookies: [] }; - } - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - assetListCalls += 1; - // Tenant-local assets empty — not fully seeded. - return { status: 200, data: [], cookies: [] }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { status: 200, data: [], cookies: [] }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return { - status: 200, - data: { - id: TENANT_ID, - name: "alice's workbench", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - // No seedModel — hub without ANTHROPIC_API_KEY. - pushWorkflow: noopPush, - log: collector().log, - }); - - // seeded: false is the typed bench_unseeded condition — the caller - // has a real membership, but the onboarding UI must keep the - // credential step open rather than read this as finished setup. - expect(result).toEqual({ - kind: "existing-member", - seeded: false, - tenantId: "ten_new", - }); - // Completeness was checked (tenant-local assets listed) even without a - // seed model — membership recovery does not short-circuit before that. - expect(assetListCalls).toBe(1); - }); - - test("isFullySeeded lists tenant-local assets only (inherited=false)", async () => { - // Root-tenant trees can surface the parent's workflow assets when - // listing with inherited=true. Those must not satisfy the seed check — - // only tenant-local assets count. Assert the query uses inherited=false - // and that empty local assets trigger a re-seed when a seed model exists. - let listedInherited = false; - let listedLocal = false; - let assetCreateCount = 0; - const startedRuns: string[] = []; - - const api: ApiCall = async (method, path, body) => { - const registry = corbitsToolsRegistryResponse( - method, - path, - TENANT_ID, - "missing", - ); - if (registry !== undefined) return registry; - const offerings = catalogOfferingsResponse(method, path, TENANT_ID); - if (offerings !== undefined) return offerings; - if (method === "GET" && path === "/api/me/principals") { - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "alice's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return { - status: 200, - data: { - id: TENANT_ID, - name: "alice's workbench", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) - ) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - return { status: 201, data: {}, cookies: [] }; - } - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - listedLocal = true; - return { status: 200, data: [], cookies: [] }; - } - if ( - method === "GET" && - path.includes("kind=workflow") && - path.includes("inherited=true") - ) { - listedInherited = true; - throw new Error("must not list inherited assets for seed completeness"); - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { - assetCreateCount += 1; - const name = - typeof body === "object" && - body !== null && - "name" in body && - typeof (body as { name: unknown }).name === "string" - ? (body as { name: string }).name - : `wf_${assetCreateCount}`; - return { - status: 201, - data: { - id: `ast_${assetCreateCount}`, - tenantId: TENANT_ID, - kind: "workflow", - name, - displayName: null, - creatorPrincipalId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/git-tokens` - ) { - return { - status: 201, - data: { id: "tok_1", secret: "s3cret" }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) - ) { - return { status: 404, data: {}, cookies: [] }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) { - return { status: 201, data: {}, cookies: [] }; - } - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { status: 200, data: [], cookies: [] }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/workflows/definitions`) - ) { - return { - status: 200, - data: { - data: [ - { - id: "wfd_digest", - tenantId: TENANT_ID, - name: "workbench-digest", - currentVersion: "1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if ( - method === "PUT" && - path === `/api/tenants/${TENANT_ID}/agent-definitions/wfd_digest/status` - ) { - return { - status: 200, - data: { id: "wfd_digest", status: "stopped" }, - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { status: 200, data: [], cookies: [] }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { - status: 201, - data: { - id: DEPLOYMENT_ID, - tenantId: TENANT_ID, - definitionAssetId: `ast_${assetCreateCount}`, - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/${DEPLOYMENT_ID}/runs` - ) { - return { - status: 200, - data: { runIds: [...startedRuns] }, - cookies: [], - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/${DEPLOYMENT_ID}/mail` - ) { - const runId = `run_${startedRuns.length + 1}`; - startedRuns.push(runId); - return { - status: 202, - data: { - runId: DEPLOYMENT_ID, - address: "echo@x", - messageId: `m${startedRuns.length}`, - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - seedModel: MODEL, - pushWorkflow: noopPush, - log: collector().log, - }); - - expect(listedLocal).toBe(true); - expect(listedInherited).toBe(false); - // Empty tenant-local assets must re-seed, not claim "already seeded" - // from an ancestor's inherited catalog. - expect(result).toEqual({ - kind: "existing-member", - seeded: true, - tenantId: "ten_new", - }); - expect(assetCreateCount).toBeGreaterThan(0); - }); - - test("a tenant with zero workflow definitions recovers a live assistant deployment on sign-in (CL-6510)", async () => { - // Reproduces the live bug verbatim: a personal bench with a real - // membership and 0 rows in workflow_definition — exactly - // tnt_b780a4d8050c8d679f107642809ab7ab's shape — hitting sign-in - // with a seed model configured. The bar this test holds itself to: - // not "seedTenant was called", but that the same read the app's own - // `/provisioning-status` route and `findMyraDefinition` depend on - // (an "assistant"-named asset with a live deployment) is genuinely - // there afterward. - const assets: { id: string; name: string }[] = []; - const deployments: { id: string; definitionAssetId: string }[] = []; - const startedRuns: Record = {}; - - const api: ApiCall = async (method, path, body) => { - const registry = corbitsToolsRegistryResponse( - method, - path, - TENANT_ID, - "missing", - ); - if (registry !== undefined) return registry; - const offerings = catalogOfferingsResponse(method, path, TENANT_ID); - if (offerings !== undefined) return offerings; - if (method === "GET" && path === "/api/me/principals") { - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "alice's team", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return { - status: 200, - data: { - id: TENANT_ID, - name: "alice's team", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) - ) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - return { status: 201, data: {}, cookies: [] }; - } - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - // 0 rows, exactly like the live tenant, until seedTenant creates - // some — every subsequent read reflects whatever exists so far. - return { - status: 200, - data: assets.map((asset) => ({ - id: asset.id, - tenantId: TENANT_ID, - kind: "workflow", - name: asset.name, - displayName: null, - creatorPrincipalId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId: TENANT_ID, direct: true }, - })), - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { - const name = - typeof body === "object" && body !== null && "name" in body - ? String((body as { name: unknown }).name) - : `wf_${assets.length + 1}`; - const asset = { id: `ast_${assets.length + 1}`, name }; - assets.push(asset); - return { - status: 201, - data: { - id: asset.id, - tenantId: TENANT_ID, - kind: "workflow", - name, - displayName: null, - creatorPrincipalId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/git-tokens` - ) { - return { - status: 201, - data: { id: "tok_1", secret: "s3cret" }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) - ) { - return { status: 404, data: {}, cookies: [] }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) { - return { status: 201, data: {}, cookies: [] }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/workflows/definitions`) - ) { - return { - status: 200, - data: { - data: [ - { - id: "wfd_digest", - tenantId: TENANT_ID, - name: "workbench-digest", - currentVersion: "1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if ( - method === "PUT" && - path === `/api/tenants/${TENANT_ID}/agent-definitions/wfd_digest/status` - ) { - return { - status: 200, - data: { id: "wfd_digest", status: "stopped" }, - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { - status: 200, - data: deployments.map((deployment) => ({ - id: deployment.id, - tenantId: TENANT_ID, - definitionAssetId: deployment.definitionAssetId, - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - })), - cookies: [], - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - const definitionAssetId = - assets[assets.length - 1]?.id ?? "ast_unknown"; - const deployment = { - id: `dep_${deployments.length + 1}`, - definitionAssetId, - }; - deployments.push(deployment); - startedRuns[deployment.id] = []; - return { - status: 201, - data: { - id: deployment.id, - tenantId: TENANT_ID, - definitionAssetId, - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - const runsMatch = - /^\/api\/tenants\/ten_new\/workflows\/(dep_\d+)\/runs$/.exec(path); - if (method === "GET" && runsMatch) { - const deploymentId = runsMatch[1] as string; - return { - status: 200, - data: { runIds: [...(startedRuns[deploymentId] ?? [])] }, - cookies: [], - }; - } - const mailMatch = - /^\/api\/tenants\/ten_new\/workflows\/(dep_\d+)\/mail$/.exec(path); - if (method === "POST" && mailMatch) { - const deploymentId = mailMatch[1] as string; - const runId = `run_${(startedRuns[deploymentId]?.length ?? 0) + 1}`; - startedRuns[deploymentId] = [ - ...(startedRuns[deploymentId] ?? []), - runId, - ]; - return { - status: 202, - data: { runId: deploymentId, address: "x@x", messageId: runId }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - // Confirm the bug is real before recovering from it: no assistant - // asset, no live deployment. - const before = await isFullySeeded(api, ["session=abc"], TENANT_ID); - expect(before).toBe(false); - - const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - seedModel: MODEL, - pushWorkflow: noopPush, - log: collector().log, - }); - - expect(result).toEqual({ - kind: "existing-member", - seeded: true, - tenantId: TENANT_ID, - }); - - // The verification bar: the same read `findMyraDefinition` and the - // `/provisioning-status` route's `setupAgentReady` depend on now - // resolves the assistant, not merely "seedTenant ran". - const status = await seededWorkflowStatus(api, ["session=abc"], TENANT_ID); - expect(status.deployed).toContain(SETUP_AGENT_ASSET_NAME); - expect(status.pending).not.toContain(SETUP_AGENT_ASSET_NAME); - }); - - test("isFullySeeded is false when corbits-tools exists but has no tarballs", async () => { - const api: ApiCall = async (method, path) => { - const registry = corbitsToolsRegistryResponse( - method, - path, - TENANT_ID, - [], - ); - if (registry !== undefined) return registry; - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { - status: 200, - data: DEFAULT_WORKFLOWS.map((workflow, index) => ({ - id: `ast_${index}`, - tenantId: TENANT_ID, - kind: "workflow", - name: workflow.assetName, - displayName: workflow.displayName, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId: TENANT_ID, direct: true }, - })), - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { - status: 200, - data: DEFAULT_WORKFLOWS.map((_workflow, index) => ({ - definitionAssetId: `ast_${index}`, - status: "deployed", - })), - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - expect(await isFullySeeded(api, ["session=abc"], TENANT_ID)).toBe(false); - }); - - test("isFullySeeded is true when workflows are live and corbits-tools carries memory-tools", async () => { - const api: ApiCall = async (method, path) => { - const registry = corbitsToolsRegistryResponse(method, path, TENANT_ID, [ - SEEDED_MEMORY_TARBALL, - ]); - if (registry !== undefined) return registry; - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { - status: 200, - data: DEFAULT_WORKFLOWS.map((workflow, index) => ({ - id: `ast_${index}`, - tenantId: TENANT_ID, - kind: "workflow", - name: workflow.assetName, - displayName: workflow.displayName, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId: TENANT_ID, direct: true }, - })), - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { - status: 200, - data: DEFAULT_WORKFLOWS.map((_workflow, index) => ({ - definitionAssetId: `ast_${index}`, - status: "deployed", - })), + status: 200, + data: DEFAULT_WORKFLOWS.map((_workflow, index) => ({ + definitionAssetId: `ast_${index}`, + status: "deployed", + })), cookies: [], }; } @@ -1903,118 +401,4 @@ describe("provisionPersonalTenantIfNeeded", () => { expect(await isFullySeeded(api, ["session=abc"], TENANT_ID)).toBe(true); }); - - test("sign-in does not republish an empty inherited corbits-tools registry", async () => { - const api: ApiCall = async (method, path) => { - const registry = corbitsToolsRegistryResponse( - method, - path, - TENANT_ID, - [], - ); - if (registry !== undefined) return registry; - const offerings = catalogOfferingsResponse(method, path, TENANT_ID); - if (offerings !== undefined) return offerings; - if (method === "GET" && path === "/api/me/principals") { - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "alice's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) - ) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - return { status: 201, data: {}, cookies: [] }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return { - status: 200, - data: { - id: TENANT_ID, - name: "alice's workbench", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, - parentId: "ten_operator", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { - status: 200, - data: DEFAULT_WORKFLOWS.map((workflow, index) => ({ - id: `ast_${index}`, - tenantId: TENANT_ID, - kind: "workflow", - name: workflow.assetName, - displayName: workflow.displayName, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId: TENANT_ID, direct: true }, - })), - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { - status: 200, - data: DEFAULT_WORKFLOWS.map((_workflow, index) => ({ - definitionAssetId: `ast_${index}`, - status: "deployed", - })), - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await provisionPersonalTenantIfNeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - pushWorkflow: noopPush, - log: collector().log, - }); - - expect(result).toEqual({ - kind: "existing-member", - seeded: false, - tenantId: TENANT_ID, - }); - }); }); diff --git a/packages/onboarding/test/report-error-routing.test.ts b/packages/onboarding/test/report-error-routing.test.ts index 7d4ea93aa..66b42461a 100644 --- a/packages/onboarding/test/report-error-routing.test.ts +++ b/packages/onboarding/test/report-error-routing.test.ts @@ -37,6 +37,17 @@ const pendingSeedStore = createInMemoryPendingSeedStore( createNoopCredentialCipher(), ); +// These tests never exercise the genesis-or-join join path — the stub +// satisfies the required tenancy wiring without standing up a DB. +const emptyHubTenancy = { + countUsers: async () => 0, + countTenants: async () => 0, + findRootTenant: async () => null, + addActiveMember: async () => { + throw new Error("these suites never exercise the join path"); + }, +}; + const asUser: MiddlewareHandler = async (c, next) => { c.set("user", { id: "user_1", email: "user_1@example.com" } as never); await next(); @@ -52,6 +63,8 @@ function mountAuthenticated(routes: Hono): Hono { describe("routes.ts routes caught errors through reportError", () => { test("a failure with no tenant known yet reports operation + userId, no tenantId", async () => { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -116,6 +129,8 @@ describe("routes.ts routes caught errors through reportError", () => { const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -262,6 +277,8 @@ describe("recentlyConnectedCredential reports through reportError and still find const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, diff --git a/packages/onboarding/test/routes.test.ts b/packages/onboarding/test/routes.test.ts index 7f3ff3224..602603cd2 100644 --- a/packages/onboarding/test/routes.test.ts +++ b/packages/onboarding/test/routes.test.ts @@ -23,6 +23,17 @@ const pendingSeedStore = createInMemoryPendingSeedStore( createNoopCredentialCipher(), ); +// These tests never exercise the genesis-or-join join path — the stub +// satisfies the required tenancy wiring without standing up a DB. +const emptyHubTenancy = { + countUsers: async () => 0, + countTenants: async () => 0, + findRootTenant: async () => null, + addActiveMember: async () => { + throw new Error("these suites never exercise the join path"); + }, +}; + const asUser: MiddlewareHandler = async (c, next) => { c.set("user", { id: "user_1", email: "alice@example.com" } as never); await next(); @@ -39,6 +50,8 @@ describe("POST /provision", () => { test("an unreachable hub surfaces a transient error envelope (503), not a bare 500 body", async () => { const lines: string[] = []; const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", // Port 0 on loopback refuses every connection immediately, so the // underlying fetch throws deterministically without a live hub. hubUrl: "http://127.0.0.1:0", @@ -85,6 +98,8 @@ describe("POST /provision", () => { const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -125,6 +140,8 @@ describe("POST /provision", () => { const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -151,6 +168,8 @@ describe("POST /provision", () => { // burn a slot — otherwise the naming wizard always 429s within 10s of // first login). const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -190,6 +209,8 @@ describe("POST /provision", () => { const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -233,6 +254,8 @@ describe("POST /provision", () => { const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -268,6 +291,8 @@ describe("POST /provision", () => { const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -296,6 +321,8 @@ describe("POST /provision", () => { test("an anonymous request is rejected before provisioning runs", async () => { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -318,6 +345,8 @@ describe("POST /provision", () => { describe("POST /complete", () => { test("an anonymous request is rejected before anything is seeded", async () => { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -345,6 +374,8 @@ describe("POST /complete", () => { test("a missing provider is rejected with a specific message, no network call made", async () => { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -386,6 +417,8 @@ describe("POST /complete", () => { const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -421,6 +454,8 @@ describe("POST /complete", () => { const providerHealth = createProviderHealthStore(); providerHealth.report("tnt_own", "anthropic", "credential_failure"); const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -459,6 +494,8 @@ describe("POST /complete", () => { test("a non-sidecar failure during setup still fails loudly with the existing 500 envelope", async () => { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -493,6 +530,8 @@ describe("POST /complete", () => { test("a HubApiError naming an absolute file path never reaches the client", async () => { const lines: string[] = []; const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: "http://127.0.0.1:0", pushWorkflow: async () => ({ outcome: "pushed" as const, @@ -580,6 +619,8 @@ describe("POST /complete — seeded-admin fallback", () => { const server = Bun.serve({ port: 0, fetch: hub.fetch }); try { const routes = createOnboardingRoutes({ + tenancy: emptyHubTenancy, + defaultTenantSlug: "workbench", hubUrl: `http://localhost:${server.port}`, pushWorkflow: async () => ({ outcome: "pushed" as const, From 347a7fada201bd6132a59c6473e963f5317533ae Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 01:52:53 -0700 Subject: [PATCH 3/5] Update docs: first signup is genesis --- docs/TENANCY.md | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/TENANCY.md b/docs/TENANCY.md index 8d6c92523..6e8bb3d82 100644 --- a/docs/TENANCY.md +++ b/docs/TENANCY.md @@ -11,14 +11,14 @@ requires an upstream Interchange change. **Do not patch `vendor/intx`.** ## What already works (consume, do not reimplement) -| Capability | Where | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| Tenant `parentId` hierarchy | `@intx/db` tenant table; POST `/api/tenants` accepts `parentId` | -| Live ancestor-chain inheritance | `getAncestorChain` in `@intx/db` — catalog, credentials, providers walk ancestors at read time | -| Descendant walk | `getDescendantTenants` in `@intx/db` | -| Roles | Interchange native `owner` / `admin` / `member` — mirror 1:1 in UI; never invent a parallel role table | -| Personal bench parenting | `packages/onboarding` parents under the boot-ensured root tenant (`WORKBENCH_DEFAULT_TENANT`, alias `ORG_SLUG`, default `workbench`) | -| Memberships | Native principal + membership routes | +| Capability | Where | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Tenant `parentId` hierarchy | `@intx/db` tenant table; POST `/api/tenants` accepts `parentId` | +| Live ancestor-chain inheritance | `getAncestorChain` in `@intx/db` — catalog, credentials, providers walk ancestors at read time | +| Descendant walk | `getDescendantTenants` in `@intx/db` | +| Roles | Interchange native `owner` / `admin` / `member` — mirror 1:1 in UI; never invent a parallel role table | +| First-signup genesis / join | `packages/onboarding`'s `genesisOrJoinHubSignup`: on an empty hub (zero tenants) the first signup mints the root tenant and becomes its `owner`; every later signup joins the root as a plain `member`. Signup never seeds workflows, tools, or grants | +| Memberships | Native principal + membership routes | Inheritance is **live**. Creating a sub-workbench must **not** copy catalog rows, credentials, or providers from the parent — resolution @@ -47,6 +47,25 @@ back to `WORKBENCH_SIGNUP` until Settings → People → "Who can join" writes one. This cutover does not migrate policy rows from a previous operator tenant. +### The 0→1 contract (first signup is genesis) + +On a hub that starts with **zero tenants and zero users** — the +`skipEnsureDefaultTenant` seam, or any deployment that opts out of +boot-time root creation — nobody has to pre-seed an admin: + +- The sign-up/email route admits the very first signup even when + `WORKBENCH_SIGNUP=closed` (the empty-hub exception in + `apps/hub/src/index.ts`'s `authHandler`: allowed only while both + `countUsers()` and `countTenants()` are zero). +- The tenant-create guard allows that caller's unparented + `POST /api/tenants` while `countTenants() === 0`. +- `genesisOrJoinHubSignup` mints the root tenant with the default + slug; the creator is its native `owner`. +- Every later signup (tenants > 0, or more than one user) joins the + existing root as a native `member` — and never mints a tenant of its + own. Signup never seeds the default workflow set; seeding belongs to + the credential step. + ## Workbench-side contracts (this repo) ### Signup mode @@ -312,7 +331,7 @@ needs a weaker role, that is an Interchange conversation first. ## Related packages - `@corbits/bench-ui` — tenancy-kind helpers, workbench-tenancy client, tenancy contracts -- `@workbench/onboarding` — personal bench provision under operator parent +- `@workbench/onboarding` — genesis-or-join first-signup provisioning - `@workbench/access-policy` — closed-by-default signup/sub-workbench- creation policy - `apps/hub` — `WORKBENCH_SIGNUP`, invite routes, icon routes; one of the From 73da8e2bf97da871870f9279faee34f7ea34cc68 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 02:07:14 -0700 Subject: [PATCH 4/5] Update e2e expectations for first-signup genesis --- scripts/e2e/cl-6324-launch-proof.ts | 98 ++++++++++++--------- scripts/e2e/cl-6329-turn-swap-proof.ts | 78 ++++++++++------- scripts/e2e/cl-6451-single-run-proof.ts | 66 +++++++++------ scripts/e2e/greeting-delivery.test.ts | 70 ++++++++++----- scripts/e2e/local-rip.test.ts | 108 +++++++++++++----------- scripts/e2e/play.ts | 58 ++++++++----- scripts/e2e/smoke-onboarding.test.ts | 44 +++------- 7 files changed, 301 insertions(+), 221 deletions(-) diff --git a/scripts/e2e/cl-6324-launch-proof.ts b/scripts/e2e/cl-6324-launch-proof.ts index 80766c99d..3347eadc4 100644 --- a/scripts/e2e/cl-6324-launch-proof.ts +++ b/scripts/e2e/cl-6324-launch-proof.ts @@ -37,6 +37,7 @@ import { } from "../../packages/seeding/src/index.ts"; import { createHubAPI, + signIn, type ApiCall, } from "../../packages/hub-api-client/src/index.ts"; import { WORKFLOW_SOURCE_ENTRY } from "../../packages/workflows/src/source.ts"; @@ -232,24 +233,37 @@ async function main(): Promise { signUp(hub.baseUrl, "CL-6324 Proof"), ); - const provisioned = await hop("first-login provisioning", async () => { - const res = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - { name: "CL-6324 Proof Bench" }, - user.cookies, - ); - expectStatus("provision", res, 200); - const data = res.data as { kind: string; tenantSlug: string }; - expect(data.kind).toBe("provisioned"); - return data; + const provisioned = await hop( + "a membership probe joins the boot root", + async () => { + const res = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + undefined, + user.cookies, + ); + expectStatus("provision probe", res, 200); + const data = res.data as { kind: string; tenantSlug: string }; + expect(data.kind).toBe("provisioned"); + return data; + }, + ); + + // A joined member is read-only by design, so every owner-level leg + // below runs as the boot admin, the root tenant's owner. + const admin = await hop("boot-admin sign-in", async () => { + const session = await signIn(hubApi, { + email: "alice@example.com", + password: "password123", + }); + return { cookies: session.cookies, userId: session.userId }; }); - const tenant = await hop("personal bench resolves", async () => { + const tenant = await hop("joined root resolves", async () => { const found = await findPersonalTenant( hubApi, - user.cookies, + admin.cookies, provisioned.tenantSlug, ); if (found === undefined) { @@ -263,10 +277,10 @@ async function main(): Promise { const connected = await hop("connect the local Ollama", async () => { const result = await testAndPersistCredential({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, - userId: user.userId, - userEmail: user.email, + userId: admin.userId, + userEmail: "alice@example.com", provider: "ollama", apiKey: OLLAMA_PLACEHOLDER_SECRET, baseURLOverride: ollamaBaseUrl, @@ -288,7 +302,7 @@ async function main(): Promise { try { await ensureSeeded({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, pushWorkflow, log: () => undefined, @@ -317,7 +331,7 @@ async function main(): Promise { try { await seedTenant({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, tenant: { tenantId: tenant.tenantId, @@ -351,7 +365,7 @@ async function main(): Promise { effect: "allow", origin: "system", }, - user.cookies, + admin.cookies, ); if (granted.status !== 201 && granted.status !== 409) { throw new Error( @@ -381,7 +395,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/catalog/models?limit=200`, undefined, - user.cookies, + admin.cookies, ); expectStatus("list the bench catalog models", models, 200); const modelRows = arrayField(models.data, "data", "catalog models") as { @@ -403,7 +417,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/catalog/offerings?limit=200`, undefined, - user.cookies, + admin.cookies, ); expectStatus("list the bench model offerings", offerings, 200); const offeringRows = arrayField( @@ -423,7 +437,7 @@ async function main(): Promise { "PATCH", `/api/tenants/${tenant.tenantId}/catalog/offerings/${offering.id}`, { disabled: true }, - user.cookies, + admin.cookies, ); expectStatus(`disable offering ${offering.id}`, patched, 200); } @@ -451,7 +465,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/invitable-definitions`, undefined, - user.cookies, + admin.cookies, ); if (res.status === 200) { const items = arrayField(res.data, "items", "invitable") as { @@ -491,7 +505,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/chat/workbenches`, { kind: "chat", definitionId: assistantDefinitionId }, - user.cookies, + admin.cookies, ); if (res.status !== 500) break; if (Date.now() > deadline) { @@ -536,7 +550,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/workflows/${agentRunId}/runs/${agentRunId}/events`, undefined, - user.cookies, + admin.cookies, ); if (res.status !== 200) return []; const raw = res.data; @@ -562,7 +576,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/workflows/${anchorRunId}/runs`, undefined, - user.cookies, + admin.cookies, ); if (res.status !== 200) return []; const raw = res.data; @@ -590,7 +604,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/insights/latency`, undefined, - user.cookies, + admin.cookies, ); expectStatus("read the turn-latency summary", res, 200); const total = (res.data as { total?: { samples?: unknown } }).total; @@ -608,7 +622,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, undefined, - user.cookies, + admin.cookies, ); expectStatus("list chat messages", res, 200); const items = arrayField(res.data, "items", "list chat messages") as { @@ -727,7 +741,7 @@ async function main(): Promise { name: SECTION_ASSET_NAME, displayName: "CL-6324 section-mode proof", }, - user.cookies, + admin.cookies, ); expectStatus("create the section-mode workflow asset", created, 201); const assetId = stringField( @@ -747,7 +761,7 @@ async function main(): Promise { actions: ["can_read", "can_push"], expiresAt: new Date(Date.now() + 600_000).toISOString(), }, - user.cookies, + admin.cookies, ); expectStatus("mint the section-mode push token", minted, 201); const tokenSecret = stringField( @@ -797,7 +811,7 @@ async function main(): Promise { sourceOfferingIds: [pinnedOfferingId], defaultSourceOfferingId: pinnedOfferingId, }, - user.cookies, + admin.cookies, ); expectStatus("deploy the section-mode workflow", deployed, 201); return stringField( @@ -837,7 +851,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/workflows/${sectionDeploymentId}/mail`, { content: text }, - user.cookies, + admin.cookies, ); if (triggered.status === 202) break; if (triggered.status !== 409 || Date.now() > triggerDeadline) { @@ -862,7 +876,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/workflows/${sectionDeploymentId}/runs/${turnRunId}/events`, undefined, - user.cookies, + admin.cookies, ); if (res.status !== 200) continue; const events = arrayField( @@ -919,7 +933,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/workflows/${sectionDeploymentId}/runs/${sectionDeploymentId}/events`, undefined, - user.cookies, + admin.cookies, ); expectStatus("read the section's parent run log", parentEvents, 200); const events = arrayField( @@ -960,7 +974,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/approvals${query}`, undefined, - user.cookies, + admin.cookies, ); if (res.status !== 200) { throw new Error( @@ -980,7 +994,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/approvals/${item.id}/approve`, { scope: "once" }, - user.cookies, + admin.cookies, ); const headline = headlineFor(item.toolDefinition, item.toolArguments); if (approved.status === 200) { @@ -1021,7 +1035,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, { parts: [{ kind: "text", text }] }, - user.cookies, + admin.cookies, ); expectStatus("send message", sent, 201); const reply = await awaitFreshReply(label); @@ -1132,7 +1146,7 @@ async function main(): Promise { }, ], }, - user.cookies, + admin.cookies, ); expectStatus("send the mid-turn message", sent, 201); // The section deployment takes the same kill mid-occurrence, so the @@ -1143,7 +1157,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/workflows/${sectionDeploymentId}/mail`, { content: MID_TURN_PROMPT }, - user.cookies, + admin.cookies, ); expectStatus("send the mid-turn section message", sectionSent, 202); // Long enough that the turn is genuinely in flight — the child has @@ -1189,7 +1203,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, undefined, - user.cookies, + admin.cookies, ); // The room surviving is a hub-only read and says nothing about // the execution plane. The run's own health does: `liveness` @@ -1202,7 +1216,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/workflows/runs/${agentRunId}/health`, undefined, - user.cookies, + admin.cookies, ); const liveness = (health.data as { liveness?: unknown }).liveness; if (res.status === 200 && liveness === "ok") return; diff --git a/scripts/e2e/cl-6329-turn-swap-proof.ts b/scripts/e2e/cl-6329-turn-swap-proof.ts index 3e478f94e..fe7c43971 100644 --- a/scripts/e2e/cl-6329-turn-swap-proof.ts +++ b/scripts/e2e/cl-6329-turn-swap-proof.ts @@ -34,6 +34,7 @@ import { } from "../../packages/seeding/src/index.ts"; import { createHubAPI, + signIn, type ApiCall, } from "../../packages/hub-api-client/src/index.ts"; import { @@ -224,24 +225,37 @@ async function main(): Promise { signUp(hub.baseUrl, "CL-6329 Proof"), ); - const provisioned = await hop("first-login provisioning", async () => { - const res = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - { name: "CL-6329 Proof Bench" }, - user.cookies, - ); - expectStatus("provision", res, 200); - const data = res.data as { kind: string; tenantSlug: string }; - expect(data.kind).toBe("provisioned"); - return data; + const provisioned = await hop( + "a membership probe joins the boot root", + async () => { + const res = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + undefined, + user.cookies, + ); + expectStatus("provision probe", res, 200); + const data = res.data as { kind: string; tenantSlug: string }; + expect(data.kind).toBe("provisioned"); + return data; + }, + ); + + // A joined member is read-only by design, so every owner-level leg + // below runs as the boot admin, the root tenant's owner. + const admin = await hop("boot-admin sign-in", async () => { + const session = await signIn(hubApi, { + email: "alice@example.com", + password: "password123", + }); + return { cookies: session.cookies, userId: session.userId }; }); - const tenant = await hop("personal bench resolves", async () => { + const tenant = await hop("joined root resolves", async () => { const found = await findPersonalTenant( hubApi, - user.cookies, + admin.cookies, provisioned.tenantSlug, ); if (found === undefined) { @@ -255,10 +269,10 @@ async function main(): Promise { const connected = await hop("connect the local Ollama", async () => { const result = await testAndPersistCredential({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, - userId: user.userId, - userEmail: user.email, + userId: admin.userId, + userEmail: "alice@example.com", provider: "ollama", apiKey: OLLAMA_PLACEHOLDER_SECRET, baseURLOverride: ollamaBaseUrl, @@ -280,7 +294,7 @@ async function main(): Promise { try { await ensureSeeded({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, pushWorkflow, log: () => undefined, @@ -309,7 +323,7 @@ async function main(): Promise { try { await seedTenant({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, tenant: { tenantId: tenant.tenantId, @@ -343,7 +357,7 @@ async function main(): Promise { effect: "allow", origin: "system", }, - user.cookies, + admin.cookies, ); if (granted.status !== 201 && granted.status !== 409) { throw new Error( @@ -371,7 +385,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/catalog/models?limit=200`, undefined, - user.cookies, + admin.cookies, ); expectStatus("list the bench catalog models", models, 200); const modelRows = arrayField(models.data, "data", "catalog models") as { @@ -393,7 +407,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/catalog/offerings?limit=200`, undefined, - user.cookies, + admin.cookies, ); expectStatus("list the bench model offerings", offerings, 200); const offeringRows = arrayField( @@ -408,7 +422,7 @@ async function main(): Promise { "PATCH", `/api/tenants/${tenant.tenantId}/catalog/offerings/${offering.id}`, { disabled: true }, - user.cookies, + admin.cookies, ); expectStatus(`disable offering ${offering.id}`, patched, 200); } @@ -428,7 +442,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/invitable-definitions`, undefined, - user.cookies, + admin.cookies, ); if (res.status === 200) { const items = arrayField(res.data, "items", "invitable") as { @@ -468,7 +482,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/chat/workbenches`, { kind: "chat", definitionId: assistantDefinitionId }, - user.cookies, + admin.cookies, ); if (res.status !== 500) break; if (Date.now() > deadline) { @@ -525,7 +539,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/workflows/${anchorRunId}/runs`, undefined, - user.cookies, + admin.cookies, ); if (res.status !== 200) return []; const raw = res.data; @@ -546,7 +560,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/turns`, undefined, - user.cookies, + admin.cookies, ); expectStatus("list the room's turns", res, 200); return arrayField(res.data, "items", "list turns") as Turn[]; @@ -561,7 +575,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, undefined, - user.cookies, + admin.cookies, ); expectStatus("list chat messages", res, 200); const items = arrayField(res.data, "items", "list chat messages") as { @@ -582,7 +596,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, { parts: [{ kind: "text", text }] }, - user.cookies, + admin.cookies, ); expectStatus(`send "${text.slice(0, 40)}"`, res, 201); } @@ -596,7 +610,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/agents`, undefined, - user.cookies, + admin.cookies, ); expectStatus("read the room's agents", res, 200); const participants = arrayField(res.data, "items", "room participants") as { @@ -621,7 +635,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/invite`, { definitionId: assistantDefinitionId }, - user.cookies, + admin.cookies, ); expectStatus("invite the second agent", res, 201); const address = stringField(res.data, "address", "invite"); @@ -806,7 +820,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/turns`, undefined, - user.cookies, + admin.cookies, ); if (res.status === 200) return; if (Date.now() > deadline) { diff --git a/scripts/e2e/cl-6451-single-run-proof.ts b/scripts/e2e/cl-6451-single-run-proof.ts index 2a14f89b8..650c0445c 100644 --- a/scripts/e2e/cl-6451-single-run-proof.ts +++ b/scripts/e2e/cl-6451-single-run-proof.ts @@ -40,6 +40,7 @@ import { } from "../../packages/seeding/src/index.ts"; import { createHubAPI, + signIn, type ApiCall, } from "../../packages/hub-api-client/src/index.ts"; import { @@ -197,24 +198,37 @@ async function main(): Promise { signUp(hub.baseUrl, "CL-6451 Proof"), ); - const provisioned = await hop("first-login provisioning", async () => { - const res = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - { name: "CL-6451 Proof Bench" }, - user.cookies, - ); - expectStatus("provision", res, 200); - const data = res.data as { kind: string; tenantSlug: string }; - expect(data.kind).toBe("provisioned"); - return data; + const provisioned = await hop( + "a membership probe joins the boot root", + async () => { + const res = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + undefined, + user.cookies, + ); + expectStatus("provision probe", res, 200); + const data = res.data as { kind: string; tenantSlug: string }; + expect(data.kind).toBe("provisioned"); + return data; + }, + ); + + // A joined member is read-only by design, so every owner-level leg + // below runs as the boot admin, the root tenant's owner. + const admin = await hop("boot-admin sign-in", async () => { + const session = await signIn(hubApi, { + email: "alice@example.com", + password: "password123", + }); + return { cookies: session.cookies, userId: session.userId }; }); - const tenant = await hop("personal bench resolves", async () => { + const tenant = await hop("joined root resolves", async () => { const found = await findPersonalTenant( hubApi, - user.cookies, + admin.cookies, provisioned.tenantSlug, ); if (found === undefined) { @@ -228,10 +242,10 @@ async function main(): Promise { const connected = await hop("connect Ollama", async () => { const result = await testAndPersistCredential({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, - userId: user.userId, - userEmail: user.email, + userId: admin.userId, + userEmail: "alice@example.com", provider: "ollama", apiKey: OLLAMA_PLACEHOLDER_SECRET, baseURLOverride: ollamaBaseUrl, @@ -252,7 +266,7 @@ async function main(): Promise { try { await ensureSeeded({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, pushWorkflow, log: () => undefined, @@ -282,7 +296,7 @@ async function main(): Promise { try { await seedTenant({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, tenant: { tenantId: tenant.tenantId, @@ -320,7 +334,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/invitable-definitions`, undefined, - user.cookies, + admin.cookies, ); if (res.status === 200) { const items = arrayField(res.data, "items", "invitable") as { @@ -349,7 +363,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/chat/workbenches`, { kind: "workbench", name: "CL-6451 proof room" }, - user.cookies, + admin.cookies, ); expectStatus("create workbench", res, 201); return stringField(res.data, "id", "create workbench"); @@ -361,7 +375,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${workbenchId}/agents`, undefined, - user.cookies, + admin.cookies, ); expectStatus("read the room's agents", res, 200); return arrayField(res.data, "items", "room participants") as { @@ -380,7 +394,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/chat/workbenches/${workbenchId}/invite`, { definitionId: assistant.id }, - user.cookies, + admin.cookies, ); if (res.status === 201) { return stringField(res.data, "address", "invite"); @@ -432,7 +446,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${workbenchId}/turns`, undefined, - user.cookies, + admin.cookies, ); expectStatus("list the room's turns", res, 200); return arrayField(res.data, "items", "list turns") as Turn[]; @@ -446,7 +460,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${workbenchId}/messages`, undefined, - user.cookies, + admin.cookies, ); expectStatus("list room messages", res, 200); const items = arrayField(res.data, "items", "list room messages") as { @@ -470,7 +484,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/chat/workbenches/${workbenchId}/messages`, { parts: [{ kind: "text", text }] }, - user.cookies, + admin.cookies, ); expectStatus(`send "${text.slice(0, 40)}"`, res, 201); // The heart of CL-6451: the `@assistant` message must NOT have been diff --git a/scripts/e2e/greeting-delivery.test.ts b/scripts/e2e/greeting-delivery.test.ts index 92e10d521..a2db0fe5d 100644 --- a/scripts/e2e/greeting-delivery.test.ts +++ b/scripts/e2e/greeting-delivery.test.ts @@ -8,12 +8,13 @@ // turns into an agent-authored workbench message on real machinery, // not merely that the route returns 201. // -// Mirrors `local-rip.test.ts`'s phase A (onboard → connect a real -// credential through the key path) rather than `chat.test.ts`'s -// zero-credential `seedCatalog` setup: the greeting mail rides the -// same host-session delivery path as every real reply, so it landing -// with zero user messages sent — on a mint wired to a genuine (stub) -// credential — proves the whole delivery chain without a paid key. +// Mirrors `local-rip.test.ts`'s phase A (signup joins the boot root as +// a member; the owner connects a credential through the key path) +// rather than `chat.test.ts`'s zero-credential `seedCatalog` setup: +// the greeting mail rides the same host-session delivery path as every +// real reply, so it landing with zero user messages sent — on a mint +// wired to a genuine (stub) credential — proves the whole delivery +// chain without a paid key. import { describe, expect, test } from "bun:test"; @@ -25,6 +26,7 @@ import { } from "../../packages/seeding/src/index.ts"; import { createHubAPI, + signIn, type ApiCall, } from "../../packages/hub-api-client/src/index.ts"; import { @@ -150,29 +152,51 @@ describe.skipIf(databaseUrl === undefined)( signUp(hub.baseUrl, "Greeting Delivery Tester"), ); + // Under the CL-7578 genesis-or-join contract the fresh signup + // joins the boot-ensured root as a plain member — the genesis + // path is covered in-process by + // `apps/hub/test/signup-genesis.test.ts`. const provisioned = await hop( - "first-login provisioning mints a personal bench, unseeded", + "a membership probe joins the boot root as a member", async () => { const res = await api( hub.baseUrl, "POST", "/api/onboarding/provision", - { name: "Greeting Delivery Tester's Bench" }, + undefined, user.cookies, ); - expectStatus("provision", res, 200); - const data = res.data as { kind: string; tenantSlug: string }; + expectStatus("provision probe", res, 200); + const data = res.data as { + kind: string; + tenantSlug: string; + seeded: boolean; + }; expect(data.kind).toBe("provisioned"); + expect(data.seeded).toBe(false); + stringField(data, "tenantSlug", "provision result"); return data; }, ); + // A joined member is read-only by design, so every owner-level + // leg below — seeding, chat mint, turns — runs as the boot admin, + // the root tenant's owner (`ensureDefaultTenant`'s config + // defaults: alice@example.com / password123). + const admin = await hop("boot-admin sign-in", async () => { + const session = await signIn(hubApi, { + email: "alice@example.com", + password: "password123", + }); + return { cookies: session.cookies, userId: session.userId }; + }); + const tenant = await hop( - "the freshly provisioned bench resolves through findPersonalTenant", + "the joined root resolves through findPersonalTenant", async () => { const found = await findPersonalTenant( hubApi, - user.cookies, + admin.cookies, provisioned.tenantSlug, ); if (found === undefined) { @@ -191,10 +215,10 @@ describe.skipIf(databaseUrl === undefined)( async () => { const testArgs = { api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, - userId: user.userId, - userEmail: user.email, + userId: admin.userId, + userEmail: "alice@example.com", provider: CONNECT_PROVIDER, apiKey: CONNECT_API_KEY, pushWorkflow, @@ -227,7 +251,7 @@ describe.skipIf(databaseUrl === undefined)( try { const seedArgs = { api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, pushWorkflow, log: () => undefined, @@ -260,7 +284,7 @@ describe.skipIf(databaseUrl === undefined)( try { await seedTenant({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, tenant: { tenantId: tenant.tenantId, @@ -269,7 +293,7 @@ describe.skipIf(databaseUrl === undefined)( }, model: await modelSourceFor( hubApi, - user.cookies, + admin.cookies, tenant.tenantId, CONNECT_PROVIDER, ), @@ -301,7 +325,7 @@ describe.skipIf(databaseUrl === undefined)( "GET", `/api/tenants/${tenant.tenantId}/chat/invitable-definitions`, undefined, - user.cookies, + admin.cookies, ); if (res.status === 200) { const items = arrayField( @@ -338,7 +362,7 @@ describe.skipIf(databaseUrl === undefined)( "POST", `/api/tenants/${tenant.tenantId}/chat/workbenches`, { kind: "chat", definitionId: assistantDefinitionId }, - user.cookies, + admin.cookies, ); if (res.status !== 500) break; if (Date.now() > deadline) { @@ -382,7 +406,7 @@ describe.skipIf(databaseUrl === undefined)( "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, undefined, - user.cookies, + admin.cookies, ); expectStatus("list chat messages", res, 200); const items = arrayField( @@ -459,7 +483,7 @@ describe.skipIf(databaseUrl === undefined)( }, ], }, - user.cookies, + admin.cookies, ); expectStatus(`send turn ${turn}`, sent, 201); const sentAt = Date.now(); @@ -470,7 +494,7 @@ describe.skipIf(databaseUrl === undefined)( "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, undefined, - user.cookies, + admin.cookies, ); const items = arrayField( res.data, diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index 6bb94f2a4..fa6f39d9d 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -4,14 +4,18 @@ // sidecar, and a real Postgres. // // Phase A (onboard → connect): closed-by-default signup is respected -// → sign up → first-login provisioning mints a personal bench, -// unseeded (no hub-owned seed model) → connecting a real inference -// credential through the key path (`POST /api/onboarding/complete`'s -// own machinery, called directly — see the stubbing note below) fully -// seeds every default workflow, including "assistant" → the -// Connections surface (the tenant's own credentials list, the same -// route `connectorStatus` in `@workbench/settings-ui` reads) honestly -// reflects the connected credential. +// → sign up → under the CL-7578 genesis-or-join contract the fresh +// signup joins the boot-ensured root as a plain member (the genesis +// path — first signup on a truly empty hub mints the root — is covered +// in-process by `apps/hub/test/signup-genesis.test.ts`) → the root's +// owner (the boot admin) connects a real inference credential through +// the key path (`POST /api/onboarding/complete`'s own machinery, +// called directly — see the stubbing note below), which fully seeds +// every default workflow, including "assistant" → the Connections +// surface (the tenant's own credentials list, the same route +// `connectorStatus` in `@workbench/settings-ui` reads) honestly +// reflects the connected credential. A joined member is read-only by +// design, so every owner-level leg runs as the boot admin. // // Until CL-6057, this suite documented a real platform gap instead of // hiding it: the "assistant" default workflow pins @@ -19,9 +23,8 @@ // had published a `package-registry`-kind asset named "corbits-tools" // carrying its tarball. CL-7071 moved that publish off `seedTenant` // onto `workbench setup` (the root tenant; descendants inherit). The -// boot-ensured root is the personal bench's parent, so the provisioned -// personal bench is a child of the root: an explicit -// `publishCorbitsToolsRegistry` hop onto that bench stands in for +// connect flow runs on the boot-ensured root itself, so an explicit +// `publishCorbitsToolsRegistry` hop onto the root stands in for // setup, then `ensureSeeded` deploys without packing. // // Stubbing note: onboarding's own `POST /api/onboarding/complete` route @@ -65,6 +68,7 @@ import { import { createHubAPI, parseAs, + signIn, type ApiCall, } from "../../packages/hub-api-client/src/index.ts"; import { @@ -138,7 +142,7 @@ async function signUp( describe.skipIf(databaseUrl === undefined)( "local-rip: onboard → connect", () => { - test("a brand-new person signs up, gets a personal bench, and connects a real provider through the key path", async () => { + test("a brand-new person signs up, joins the root as a member, and the owner connects a real provider through the key path", async () => { const url = databaseUrl; if (url === undefined) throw new Error("unreachable: suite is skipped"); @@ -194,10 +198,8 @@ describe.skipIf(databaseUrl === undefined)( ).toString("hex"), dataDir: await tempDir("e2e-local-rip-hub-data-"), // Deliberately no ANTHROPIC_API_KEY: like `smoke-onboarding`, - // this hub carries no hub-owned seed model credential, so - // first-login provisioning must report the bench as - // provisioned-but-unseeded — this scenario's own connect step - // is what finishes seeding it. + // this hub carries no hub-owned seed model credential — this + // scenario's own connect step is what finishes seeding it. }), ); track(hub); @@ -208,32 +210,30 @@ describe.skipIf(databaseUrl === undefined)( signUp(hub.baseUrl, "Local Rip Tester"), ); - await hop( - "a membership probe before naming reports needs-onboarding", - async () => { - const res = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - user.cookies, - ); - expectStatus("provision probe", res, 200); - expect((res.data as { kind: string }).kind).toBe("needs-onboarding"); - }, - ); + // The boot admin — the root tenant's owner, seeded by + // `ensureDefaultTenant` with the config defaults this hub env + // leaves unset (alice@example.com / password123). Every + // owner-level leg below runs as this identity: a joined member is + // read-only by design. + const admin = await hop("boot-admin sign-in", async () => { + const session = await signIn(hubApi, { + email: "alice@example.com", + password: "password123", + }); + return { cookies: session.cookies, userId: session.userId }; + }); const provisioned = await hop( - "first-login provisioning mints a personal bench, unseeded", + "a membership probe joins the boot root as a member", async () => { const res = await api( hub.baseUrl, "POST", "/api/onboarding/provision", - { name: "Local Rip Tester's Bench" }, + undefined, user.cookies, ); - expectStatus("provision", res, 200); + expectStatus("provision probe", res, 200); const data = res.data as { kind: string; tenantId: string; @@ -243,11 +243,26 @@ describe.skipIf(databaseUrl === undefined)( }; expect(data.kind).toBe("provisioned"); expect(data.seeded).toBe(false); - expect(typeof data.seedSkipReason).toBe("string"); + expect(data.seedSkipReason).toBeUndefined(); + stringField(data, "tenantId", "provision result"); + stringField(data, "tenantSlug", "provision result"); return data; }, ); + await hop("re-provisioning the same account is idempotent", async () => { + const res = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + { name: "Local Rip Tester's Bench" }, + user.cookies, + ); + expectStatus("re-provision", res, 200); + const data = res.data as { kind: string }; + expect(data.kind).toBe("existing-member"); + }); + await hop( "the provisioned bench is a real tenant membership", async () => { @@ -274,7 +289,7 @@ describe.skipIf(databaseUrl === undefined)( async () => { const found = await findPersonalTenant( hubApi, - user.cookies, + admin.cookies, provisioned.tenantSlug, ); if (found === undefined) { @@ -294,10 +309,10 @@ describe.skipIf(databaseUrl === undefined)( async () => { const result = await testAndPersistCredential({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, - userId: user.userId, - userEmail: user.email, + userId: admin.userId, + userEmail: "alice@example.com", provider: "anthropic", apiKey: STUB_API_KEY, pushWorkflow, @@ -333,7 +348,7 @@ describe.skipIf(databaseUrl === undefined)( try { return await seedTenant({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, tenant: { tenantId: tenant.tenantId, @@ -342,7 +357,7 @@ describe.skipIf(databaseUrl === undefined)( }, model: await modelSourceFor( hubApi, - user.cookies, + admin.cookies, tenant.tenantId, "anthropic", ), @@ -358,17 +373,16 @@ describe.skipIf(databaseUrl === undefined)( } } - // CL-7071: seedTenant/ensureSeeded no longer pack. The provisioned - // personal bench is a child of the boot-ensured root, so publish - // `corbits-tools` onto the bench itself the way `workbench setup` - // does onto the root. Then ensureSeeded deploys assistant without - // packing. + // CL-7071: seedTenant/ensureSeeded no longer pack. The connect + // flow runs on the boot-ensured root itself, so publish + // `corbits-tools` onto the root the way `workbench setup` does. + // Then ensureSeeded deploys assistant without packing. await hop( "publish corbits-tools onto the provisioned root bench (setup's job, not seed's)", async () => { await publishCorbitsToolsRegistry({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, tenantId: tenant.tenantId, log: () => undefined, @@ -389,7 +403,7 @@ describe.skipIf(databaseUrl === undefined)( try { await ensureSeeded({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, pushWorkflow, log: () => undefined, diff --git a/scripts/e2e/play.ts b/scripts/e2e/play.ts index 16fa05391..3fe98b82b 100644 --- a/scripts/e2e/play.ts +++ b/scripts/e2e/play.ts @@ -22,6 +22,7 @@ import { } from "../../packages/seeding/src/index.ts"; import { createHubAPI, + signIn, type ApiCall, } from "../../packages/hub-api-client/src/index.ts"; import { @@ -183,28 +184,43 @@ async function main(): Promise { ); const provisioned = await hop( - "first-login provisioning mints a personal bench, unseeded", + "a membership probe joins the boot root as a member", async () => { const res = await api( hub.baseUrl, "POST", "/api/onboarding/provision", - { name: "Greeting Delivery Tester's Bench" }, + undefined, user.cookies, ); - expectStatus("provision", res, 200); - const data = res.data as { kind: string; tenantSlug: string }; + expectStatus("provision probe", res, 200); + const data = res.data as { + kind: string; + tenantSlug: string; + seeded: boolean; + }; expect(data.kind).toBe("provisioned"); + expect(data.seeded).toBe(false); return data; }, ); + // A joined member is read-only by design, so every owner-level leg + // below runs as the boot admin, the root tenant's owner. + const admin = await hop("boot-admin sign-in", async () => { + const session = await signIn(hubApi, { + email: "alice@example.com", + password: "password123", + }); + return { cookies: session.cookies, userId: session.userId }; + }); + const tenant = await hop( - "the freshly provisioned bench resolves through findPersonalTenant", + "the joined root resolves through findPersonalTenant", async () => { const found = await findPersonalTenant( hubApi, - user.cookies, + admin.cookies, provisioned.tenantSlug, ); if (found === undefined) { @@ -223,10 +239,10 @@ async function main(): Promise { async () => { const testArgs = { api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, - userId: user.userId, - userEmail: user.email, + userId: admin.userId, + userEmail: "alice@example.com", provider: CONNECT_PROVIDER, apiKey: CONNECT_API_KEY, pushWorkflow, @@ -259,7 +275,7 @@ async function main(): Promise { try { const seedArgs = { api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, pushWorkflow, log: () => undefined, @@ -292,7 +308,7 @@ async function main(): Promise { try { await seedTenant({ api: hubApi, - cookies: user.cookies, + cookies: admin.cookies, hubUrl: hub.baseUrl, tenant: { tenantId: tenant.tenantId, @@ -301,7 +317,7 @@ async function main(): Promise { }, model: await modelSourceFor( hubApi, - user.cookies, + admin.cookies, tenant.tenantId, CONNECT_PROVIDER, ), @@ -333,7 +349,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/invitable-definitions`, undefined, - user.cookies, + admin.cookies, ); if (res.status === 200) { const items = arrayField( @@ -370,7 +386,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/chat/workbenches`, { kind: "chat", definitionId: assistantDefinitionId }, - user.cookies, + admin.cookies, ); if (res.status !== 500) break; if (Date.now() > deadline) { @@ -420,7 +436,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, undefined, - user.cookies, + admin.cookies, ); expectStatus("list chat messages", res, 200); const items = arrayField(res.data, "items", "list chat messages") as { @@ -480,7 +496,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, undefined, - user.cookies, + admin.cookies, ); const items = arrayField(res.data, "items", "list") as { id: string; @@ -523,7 +539,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/approvals${query}`, undefined, - user.cookies, + admin.cookies, ); if (res.status !== 200) { throw new Error( @@ -543,7 +559,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/approvals/${item.id}/approve`, { scope: "once" }, - user.cookies, + admin.cookies, ); const headline = headlineFor(item.toolDefinition, item.toolArguments); if (approved.status === 200) { @@ -566,7 +582,7 @@ async function main(): Promise { "POST", `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, { parts: [{ kind: "text", text: human }] }, - user.cookies, + admin.cookies, ); expectStatus("send", sent, 201); const t0 = Date.now(); @@ -648,7 +664,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/routines`, undefined, - user.cookies, + admin.cookies, ); console.log("\nROUTINES:", JSON.stringify(routines.data).slice(0, 800)); const invitable = await api( @@ -656,7 +672,7 @@ async function main(): Promise { "GET", `/api/tenants/${tenant.tenantId}/chat/invitable`, undefined, - user.cookies, + admin.cookies, ); console.log("AGENTS:", JSON.stringify(invitable.data).slice(0, 800)); } diff --git a/scripts/e2e/smoke-onboarding.test.ts b/scripts/e2e/smoke-onboarding.test.ts index 1c63fe40f..d89ed57fa 100644 --- a/scripts/e2e/smoke-onboarding.test.ts +++ b/scripts/e2e/smoke-onboarding.test.ts @@ -1,12 +1,13 @@ // Smoke scenario 2/5 (CL-6004): provisioning. A signed-up user with no // tenant yet calls the first-login provisioning hook -// (POST /api/onboarding/provision); it mints a personal bench through -// the native tenant-creation route. The e2e hub never carries -// ANTHROPIC_API_KEY, so no hub-owned seed model credential is -// configured — this asserts that documented, typed condition of the -// response contract (`seeded: false` with a `seedSkipReason`) rather -// than exercising full default-workflow seeding, which needs a real -// inference credential this suite deliberately never has. +// (POST /api/onboarding/provision). The e2e hub boots with the +// boot-ensured root tenant present, so under the CL-7578 genesis-or- +// join contract the fresh signup joins that root as a plain member — +// the genesis path (first signup on a truly empty hub mints the root +// itself) is covered in-process by +// `apps/hub/test/signup-genesis.test.ts`. This asserts the join over +// the wire: `kind: "provisioned"` naming the joined root, `seeded: +// false` (signup never seeds), and an idempotent re-provision. import { describe, expect, test } from "bun:test"; @@ -45,7 +46,7 @@ function stringField(data: unknown, field: string, what: string): string { describe.skipIf(databaseUrl === undefined)( "smoke: onboarding provision", () => { - test("provisioning a personal bench without a seed model reports bench_unseeded", async () => { + test("a brand-new signup joins the boot root as a member, unseeded", async () => { const url = databaseUrl; if (url === undefined) throw new Error("unreachable: suite is skipped"); @@ -63,9 +64,8 @@ describe.skipIf(databaseUrl === undefined)( crypto.getRandomValues(new Uint8Array(32)), ).toString("hex"), dataDir, - // Deliberately no ANTHROPIC_API_KEY: the hub carries no - // hub-owned seed model credential, so provisioning must - // report the bench as provisioned-but-unseeded. + // The e2e hub carries no hub-owned seed model credential, and + // the join path never seeds regardless. }), ); track(hub); @@ -84,8 +84,8 @@ describe.skipIf(databaseUrl === undefined)( return res.cookies; }); - await hop( - "a membership probe before naming reports needs-onboarding", + const provisioned = await hop( + "a membership probe joins the boot root as a member", async () => { const res = await api( baseUrl, @@ -95,21 +95,6 @@ describe.skipIf(databaseUrl === undefined)( cookies, ); expectStatus("provision probe", res, 200); - expect((res.data as { kind: string }).kind).toBe("needs-onboarding"); - }, - ); - - const provisioned = await hop( - "provisioning with a display name mints a personal bench, unseeded", - async () => { - const res = await api( - baseUrl, - "POST", - "/api/onboarding/provision", - { name: "Onboarding Smoke Tester's Bench" }, - cookies, - ); - expectStatus("provision", res, 200); const data = res.data as { kind: string; tenantId: string; @@ -119,8 +104,7 @@ describe.skipIf(databaseUrl === undefined)( }; expect(data.kind).toBe("provisioned"); expect(data.seeded).toBe(false); - expect(typeof data.seedSkipReason).toBe("string"); - expect(data.seedSkipReason).not.toBe(""); + expect(data.seedSkipReason).toBeUndefined(); stringField(data, "tenantId", "provision result"); stringField(data, "tenantSlug", "provision result"); return data; From 518ef5b67a91318e17192f0d85fe293a075f4b11 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 02:42:29 -0700 Subject: [PATCH 5/5] Gate join on the signup policy and finish member signup outcomes --- apps/hub/src/index.ts | 3 + apps/hub/src/tenant-create-guard.ts | 4 +- apps/hub/test/signup-genesis.test.ts | 126 +++++++++- apps/web/src/onboarding.ts | 16 +- docs/local-rip.md | 6 +- packages/onboarding/src/genesis.ts | 43 +++- packages/onboarding/src/provision.ts | 34 ++- packages/onboarding/test/genesis.test.ts | 272 +++++++++++++++++---- packages/onboarding/test/provision.test.ts | 9 +- scripts/e2e/cl-6324-launch-proof.ts | 2 +- scripts/e2e/cl-6329-turn-swap-proof.ts | 2 +- scripts/e2e/cl-6451-single-run-proof.ts | 2 +- scripts/e2e/greeting-delivery.test.ts | 3 +- scripts/e2e/local-rip.test.ts | 4 +- scripts/e2e/play.ts | 3 +- scripts/e2e/smoke-onboarding.test.ts | 9 +- 16 files changed, 443 insertions(+), 95 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index c32a6ea9c..77d537938 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -1286,6 +1286,9 @@ export async function createHub(config: HubConfig) { // tenants, someone has to be first — the signup that opens a // brand-new hub is allowed even when signup is closed, since // the genesis path makes that caller the root tenant's owner. + // Everywhere else on the hub "empty" means zero tenants only; + // here a user row also counts, because it means the 0→1 + // signup already happened. const [users, tenants] = await Promise.all([ signupTenancy.countUsers(), signupTenancy.countTenants(), diff --git a/apps/hub/src/tenant-create-guard.ts b/apps/hub/src/tenant-create-guard.ts index 602e9dd13..85047f74e 100644 --- a/apps/hub/src/tenant-create-guard.ts +++ b/apps/hub/src/tenant-create-guard.ts @@ -122,7 +122,9 @@ export async function decideTenantCreate( // policy, no operator tenant, and nobody to invite anyone — the // first signup's unparented create is the one path that bypasses // the signup gate, because the sign-up route's own empty-hub - // exception already admitted this caller. + // exception already admitted this caller. Empty means zero tenants + // only (the one emptiness rule shared with genesis.ts; user rows + // alone never make a hub occupied). if (request.parentId === undefined && (await deps.countTenants()) === 0) { return { allowed: true }; } diff --git a/apps/hub/test/signup-genesis.test.ts b/apps/hub/test/signup-genesis.test.ts index c8de648fc..c6623fb5c 100644 --- a/apps/hub/test/signup-genesis.test.ts +++ b/apps/hub/test/signup-genesis.test.ts @@ -12,7 +12,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import postgres from "postgres"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { principal, principalRole, @@ -83,6 +83,7 @@ async function bootEmptyHub(args: { }): Promise<{ baseUrl: string; db: Awaited>["db"]; + stop: () => Promise; }> { const root = mkdtempSync(path.join(tmpdir(), "hub-signup-genesis-")); const staticDir = path.join(root, "static"); @@ -126,21 +127,36 @@ async function bootEmptyHub(args: { }; const hub = await createHub(config); server.reload({ fetch: hub.app.fetch }); - closers.push(async () => { + let stopped = false; + const stop = async () => { + if (stopped) return; + stopped = true; server.stop(true); await hub.close(); rmSync(root, { recursive: true, force: true }); - }); - return { baseUrl, db: hub.db }; + }; + closers.push(stop); + return { baseUrl, db: hub.db, stop }; } +// Distinct client IP per sign-up: better-auth's rate-limit storage is +// shared across every hub instance in this test process and keyed on +// the resolved client IP, so without this the suite's sign-ups all +// land in one budget bucket and can starve sibling suites +// (composition.test.ts signs up against the same bucket). +let signUpIpCounter = 0; + async function signUp( baseUrl: string, args: { name: string; email: string; password: string }, ): Promise { + signUpIpCounter += 1; const response = await fetch(`${baseUrl}/api/auth/sign-up/email`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + "x-real-ip": `198.51.100.${signUpIpCounter}`, + }, body: JSON.stringify(args), }); expect(response.status).toBe(200); @@ -290,9 +306,8 @@ describeIfDb("signup genesis (CL-7578)", () => { // The join path needs no display name: a plain membership probe // is enough, because the root already exists. const joined = await provision(baseUrl, bob); - expect(joined.kind).toBe("provisioned"); + expect(joined.kind).toBe("existing-member"); expect(joined.tenantSlug).toBe("workbench"); - expect(joined.seeded).toBe(false); expect(joined.tenantId).toBe(genesis.tenantId); const tenants = await db.select().from(tenant); @@ -315,4 +330,101 @@ describeIfDb("signup genesis (CL-7578)", () => { ).toEqual(["member"]); }); }); + + test("an operator-removed member cannot self-rejoin on a closed hub", async () => { + const scratchUrl = scratchUrlFor("rejoin"); + await withScratchDatabase(scratchUrl, async () => { + const open = await bootEmptyHub({ + scratchUrl, + signupMode: "open", + }); + + const alice = await signUp(open.baseUrl, { + name: "Alice", + email: "alice@example.com", + password: "password123", + }); + await provision(open.baseUrl, alice, "Acme"); + const bob = await signUp(open.baseUrl, { + name: "Bob", + email: "bob@example.com", + password: "password123", + }); + const joined = await provision(open.baseUrl, bob); + expect(joined.kind).toBe("existing-member"); + + // Operator removal: native removal deletes the member's + // principal rows outright, leaving the account itself alive. + const [bobRow] = await open.db + .select({ id: userTable.id }) + .from(userTable) + .where(eq(userTable.email, "bob@example.com")) + .limit(1); + expect(bobRow).toBeDefined(); + await open.db.delete(principalRole).where( + inArray( + principalRole.principalId, + open.db + .select({ id: principal.id }) + .from(principal) + .where( + and( + eq(principal.kind, "user"), + eq(principal.refId, bobRow?.id ?? ""), + ), + ), + ), + ); + await open.db + .delete(principal) + .where( + and( + eq(principal.kind, "user"), + eq(principal.refId, bobRow?.id ?? ""), + ), + ); + + // The hub flips signup to closed and restarts on the same data. + await open.stop(); + const closed = await bootEmptyHub({ + scratchUrl, + signupMode: "closed", + }); + + const signIn = await fetch(`${closed.baseUrl}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "bob@example.com", + password: "password123", + }), + }); + expect(signIn.status).toBe(200); + const sessionCookies = signIn.headers.getSetCookie(); + + const res = await fetch(`${closed.baseUrl}/api/onboarding/provision`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: sessionCookies.join("; "), + }, + }); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("signup_not_allowed"); + + // No principal was minted: the removal sticks. + const [bobAfter] = await closed.db + .select({ id: principal.id }) + .from(principal) + .where( + and( + eq(principal.kind, "user"), + eq(principal.refId, bobRow?.id ?? ""), + ), + ) + .limit(1); + expect(bobAfter).toBeUndefined(); + }); + }); }); diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index c40636296..48a862125 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -97,6 +97,10 @@ export type ProvisionOutcome = * A probe `error` must not be treated as absent (CL-6868). */ readonly tenantId?: string; + /** Present when the caller just joined the root as a plain + * member: the tenant they joined, for the member-onboarding UX + * to surface later (CL-7584). */ + readonly tenantSlug?: string; } | { readonly kind: "needs-onboarding" } | { @@ -141,7 +145,17 @@ export async function triggerFirstLoginProvisioning( return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; } if (parsed.kind === "existing-member") { - if (parsed.seeded === undefined) return { kind: "existing-member" }; + if (parsed.seeded === undefined) { + return parsed.tenantId === undefined + ? { kind: "existing-member" } + : { + kind: "existing-member", + tenantId: parsed.tenantId, + ...(parsed.tenantSlug !== undefined + ? { tenantSlug: parsed.tenantSlug } + : {}), + }; + } return parsed.tenantId === undefined ? { kind: "existing-member", seeded: parsed.seeded } : { diff --git a/docs/local-rip.md b/docs/local-rip.md index 432105edb..2bdfbe1c5 100644 --- a/docs/local-rip.md +++ b/docs/local-rip.md @@ -68,8 +68,10 @@ exactly that response. Signing up lands you on `/onboarding`. Submitting the "Create your workbench" name form calls `POST /api/onboarding/provision` with that name, which mints your personal bench through the platform's native -tenant-creation route. With no `ANTHROPIC_API_KEY` configured, the response -reports the bench as provisioned but unseeded (`seeded: false`, with a +tenant-creation route. (An occupied hub joins you to its root as a plain +member instead — `kind: "existing-member"` naming the tenant you joined, +no wizard.) With no `ANTHROPIC_API_KEY` configured, a minted bench is +unseeded (`seeded: false`, with a `seedSkipReason` naming why) — the UI keeps you on the credential step rather than pretending you're done. diff --git a/packages/onboarding/src/genesis.ts b/packages/onboarding/src/genesis.ts index dea8ea2db..4299544c3 100644 --- a/packages/onboarding/src/genesis.ts +++ b/packages/onboarding/src/genesis.ts @@ -4,7 +4,10 @@ import { paginatedSchema, PrincipalSummary, TenantResponse } from "@intx/types"; import { parseAs, type ApiCall } from "@corbits/hub-api-client"; -import type { AccessPolicyStore } from "@workbench/access-policy"; +import { + checkSignupGate, + type AccessPolicyStore, +} from "@workbench/access-policy"; export type HubSignupTenancy = { countUsers(): Promise; @@ -100,6 +103,34 @@ async function joinRoot( }; } +/** + * Runs the signup gate before minting anything. `emptyHubException` + * waives only `signup_closed`: with zero tenants somebody has to be + * first, but email trust and the domain allowlist still bind even the + * genesis caller. A rejection is always a 403-shaped `ProvisionError`. + */ +async function requireSignupAllowed( + args: GenesisOrJoinArgs, + options: { readonly emptyHubException: boolean }, +): Promise { + if (args.accessPolicy === undefined) return; + const gate = await checkSignupGate({ + store: args.accessPolicy.store, + envSignupMode: args.accessPolicy.envSignupMode, + envAllowedDomains: args.accessPolicy.envAllowedDomains, + email: args.userEmail, + emailVerified: args.userEmailVerified, + allowUnverifiedEmails: args.accessPolicy.allowUnverifiedEmails, + }); + if (gate.allowed) return; + if (options.emptyHubException && gate.reason === "signup_closed") return; + throw new ProvisionError( + "signup_not_allowed", + `signup gate rejected ${args.userEmail} (${gate.reason})`, + "permanent", + ); +} + async function requireRoot(tenancy: HubSignupTenancy): Promise<{ id: string; slug: string; @@ -121,12 +152,18 @@ export async function genesisOrJoinHubSignup( const before = await fetchPrincipals(args.api, args.cookies); if (before.length > 0) return { kind: "existing-member" }; + // Empty hub = zero tenants, regardless of user count: user rows + // without a tenant (an invite that never landed, a member removed by + // an operator) must not strand the hub — anyone principal-less may + // still genesis when no tenant exists. const tenantCount = await args.tenancy.countTenants(); - const userCount = await args.tenancy.countUsers(); - if (tenantCount > 0 || userCount > 1) { + if (tenantCount > 0) { + await requireSignupAllowed(args, { emptyHubException: false }); return joinRoot(args, await requireRoot(args.tenancy)); } + await requireSignupAllowed(args, { emptyHubException: true }); + if (args.displayName === undefined || args.displayName.trim().length === 0) { return { kind: "needs-onboarding" }; } diff --git a/packages/onboarding/src/provision.ts b/packages/onboarding/src/provision.ts index c54dfa45f..2efbdf720 100644 --- a/packages/onboarding/src/provision.ts +++ b/packages/onboarding/src/provision.ts @@ -21,13 +21,20 @@ export { ProvisionError } from "./genesis"; export type { ProvisionErrorKind } from "./genesis"; export type ProvisionResult = - | { readonly kind: "existing-member" } + | { + /** An account that already belongs somewhere — or has just joined + * the existing root as a plain member. Either way no wizard: the + * tenant facts ride along only when the caller just joined, for + * the member-onboarding UX to surface later (CL-7584). */ + readonly kind: "existing-member"; + readonly tenantId?: string; + readonly tenantSlug?: string; + } | { readonly kind: "needs-onboarding" } | { - /** The caller now belongs to a tenant — as the owner of a - * freshly-minted root (genesis) or as a new member of the - * existing root (join). `seeded` is always `false`: signup - * provisions membership only; the credential step owns seeding. */ + /** The caller just minted the root tenant as its owner (genesis). + * `seeded` is always `false`: signup provisions membership only; + * the credential step owns seeding. */ readonly kind: "provisioned"; readonly tenantId: string; readonly tenantSlug: string; @@ -53,10 +60,10 @@ export type ProvisionArgs = { displayName?: string; tenancy: HubSignupTenancy; log: (line: string) => void; - /** The closed-by-default access-policy gate. Genesis-on-empty and - * join never consult it — those decisions belong to the sign-up - * route (empty-hub exception) and the tenant-create guard — but the - * seam stays so the wiring shape is unchanged. */ + /** The closed-by-default access-policy gate. Join consults it + * outright (a closed hub never self-grants membership); genesis on an + * empty hub waives only `signup_closed` — email verification and the + * domain allowlist still bind the first user. */ accessPolicy?: { store: AccessPolicyStore; envSignupMode: "open" | "closed"; @@ -195,7 +202,7 @@ export async function provisionPersonalTenantIfNeeded( ? { displayName: args.displayName } : {}), }); - if (result.kind === "genesis" || result.kind === "joined") { + if (result.kind === "genesis") { return { kind: "provisioned", tenantId: result.tenantId, @@ -203,5 +210,12 @@ export async function provisionPersonalTenantIfNeeded( seeded: false, }; } + if (result.kind === "joined") { + return { + kind: "existing-member", + tenantId: result.tenantId, + tenantSlug: result.tenantSlug, + }; + } return result; } diff --git a/packages/onboarding/test/genesis.test.ts b/packages/onboarding/test/genesis.test.ts index 87e42b420..4b83812c2 100644 --- a/packages/onboarding/test/genesis.test.ts +++ b/packages/onboarding/test/genesis.test.ts @@ -18,19 +18,61 @@ function collector() { return { lines, log: (line: string) => lines.push(line) }; } -function throwingAccessPolicy(): NonNullable< - GenesisOrJoinArgs["accessPolicy"] -> { - const store = { - getPolicy: async () => { - throw new Error("checkSignupGate must not run for genesis or join"); - }, - } as unknown as AccessPolicyStore; +/** An ApiCall that lets a genesis mint succeed: the first principals + * read is empty, the create answers 201, and every later principals + * read reports membership in the minted tenant. */ +function genesisApi(): ApiCall { + let principalsCalls = 0; + return async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + principalsCalls += 1; + if (principalsCalls === 1) return principalsResponse([]); + return principalsResponse([ + { + principalId: PRINCIPAL_ID, + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + }, + ]); + } + if (method === "POST" && path === "/api/tenants") { + return { + status: 201, + data: { + id: TENANT_ID, + name: "Acme", + slug: TENANT_SLUG, + domain: `${TENANT_SLUG}.localhost`, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; +} + +function accessPolicy( + overrides?: Partial< + Pick< + NonNullable, + "envSignupMode" | "envAllowedDomains" | "allowUnverifiedEmails" + > + >, +): NonNullable { return { - store, - envSignupMode: "closed", + // Env-only evaluation: no operator tenant is threaded here, so the + // store is never read — a throwing store proves that. + store: { + getPolicy: async () => { + throw new Error("signup gate must evaluate env-only here"); + }, + } as unknown as AccessPolicyStore, + envSignupMode: "open", envAllowedDomains: [], allowUnverifiedEmails: false, + ...overrides, }; } @@ -82,16 +124,19 @@ function argsFor(partial: { tenancy: HubSignupTenancy; displayName?: string; log?: (line: string) => void; + accessPolicy?: NonNullable; + userEmailVerified?: boolean; + userEmail?: string; }): GenesisOrJoinArgs { const args: GenesisOrJoinArgs = { api: partial.api, cookies: ["session=abc"], userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, + userEmail: partial.userEmail ?? "alice@example.com", + userEmailVerified: partial.userEmailVerified ?? true, defaultTenantSlug: TENANT_SLUG, tenancy: partial.tenancy, - accessPolicy: throwingAccessPolicy(), + accessPolicy: partial.accessPolicy ?? accessPolicy(), log: partial.log ?? collector().log, }; if (partial.displayName !== undefined) args.displayName = partial.displayName; @@ -173,14 +218,42 @@ describe("genesisOrJoinHubSignup", () => { ]); }); - test("countUsers > 1 with zero tenants still joins the root rather than minting", async () => { + test("a closed hub rejects join with signup_not_allowed and never adds a member", async () => { const joins: { tenantId: string; userId: string; roleName: string }[] = []; const api: ApiCall = async (method, path) => { if (method === "GET" && path === "/api/me/principals") { return principalsResponse([]); } - if (method === "POST" && path === "/api/tenants") { - throw new Error("must not mint when another user already exists"); + throw new Error(`unexpected call: ${method} ${path}`); + }; + + await expect( + genesisOrJoinHubSignup( + argsFor({ + api, + tenancy: tenancy({ + users: 2, + tenants: 1, + root: { id: TENANT_ID, slug: TENANT_SLUG }, + joins, + }), + displayName: "Bob", + accessPolicy: accessPolicy({ envSignupMode: "closed" }), + }), + ), + ).rejects.toMatchObject({ + name: "ProvisionError", + code: "signup_not_allowed", + errorKind: "permanent", + }); + expect(joins).toEqual([]); + }); + + test("an open hub still joins with a closed-store policy absent: env decides", async () => { + const joins: { tenantId: string; userId: string; roleName: string }[] = []; + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse([]); } throw new Error(`unexpected call: ${method} ${path}`); }; @@ -190,11 +263,10 @@ describe("genesisOrJoinHubSignup", () => { api, tenancy: tenancy({ users: 2, - tenants: 0, + tenants: 1, root: { id: TENANT_ID, slug: TENANT_SLUG }, joins, }), - displayName: "Bob", }), ); @@ -202,6 +274,130 @@ describe("genesisOrJoinHubSignup", () => { expect(joins).toHaveLength(1); }); + test("genesis rejects an unverified email unless allowUnverifiedEmails", async () => { + const probeOnly: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse([]); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + await expect( + genesisOrJoinHubSignup( + argsFor({ + api: probeOnly, + tenancy: tenancy({ users: 1, tenants: 0, root: null }), + displayName: "Acme", + userEmailVerified: false, + }), + ), + ).rejects.toMatchObject({ + name: "ProvisionError", + code: "signup_not_allowed", + }); + + // The dev/test escape hatch still admits the genesis caller. + const result = await genesisOrJoinHubSignup( + argsFor({ + api: genesisApi(), + tenancy: tenancy({ users: 1, tenants: 0, root: null }), + displayName: "Acme", + userEmailVerified: false, + accessPolicy: accessPolicy({ allowUnverifiedEmails: true }), + }), + ); + expect(result.kind).toBe("genesis"); + }); + + test("genesis enforces the env domain allowlist for the first user", async () => { + const probeOnly: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse([]); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + await expect( + genesisOrJoinHubSignup( + argsFor({ + api: probeOnly, + tenancy: tenancy({ users: 1, tenants: 0, root: null }), + displayName: "Acme", + userEmail: "alice@example.com", + accessPolicy: accessPolicy({ envAllowedDomains: ["corp.com"] }), + }), + ), + ).rejects.toMatchObject({ + name: "ProvisionError", + code: "signup_not_allowed", + }); + + const result = await genesisOrJoinHubSignup( + argsFor({ + api: genesisApi(), + tenancy: tenancy({ users: 1, tenants: 0, root: null }), + displayName: "Acme", + userEmail: "alice@corp.com", + accessPolicy: accessPolicy({ + envSignupMode: "closed", + envAllowedDomains: ["corp.com"], + }), + }), + ); + expect(result.kind).toBe("genesis"); + }); + + test("countUsers > 1 with zero tenants still lets the caller genesis", async () => { + let tenantCreates = 0; + const joins: { tenantId: string; userId: string; roleName: string }[] = []; + let principalsCalls = 0; + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + principalsCalls += 1; + if (principalsCalls === 1) return principalsResponse([]); + return principalsResponse([ + { + principalId: PRINCIPAL_ID, + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + }, + ]); + } + if (method === "POST" && path === "/api/tenants") { + tenantCreates += 1; + return { + status: 201, + data: { + id: TENANT_ID, + name: "Acme", + slug: TENANT_SLUG, + domain: `${TENANT_SLUG}.localhost`, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await genesisOrJoinHubSignup( + argsFor({ + api, + tenancy: tenancy({ users: 2, tenants: 0, root: null, joins }), + displayName: "Acme", + }), + ); + + expect(result).toEqual({ + kind: "genesis", + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + }); + expect(tenantCreates).toBe(1); + expect(joins).toEqual([]); + }); + test("zero tenants and no display name returns needs-onboarding and creates nothing", async () => { let tenantCreates = 0; const api: ApiCall = async (method, path) => { @@ -364,44 +560,18 @@ describe("genesisOrJoinHubSignup", () => { }); }); - test("closed accessPolicy is never consulted for genesis or join", async () => { - let principalsCalls = 0; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - principalsCalls += 1; - if (principalsCalls === 1) return principalsResponse([]); - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - return { - status: 201, - data: { - id: TENANT_ID, - name: "Acme", - slug: TENANT_SLUG, - domain: `${TENANT_SLUG}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - await genesisOrJoinHubSignup( + test("genesis on an empty hub waives signup_closed but still evaluates the gate", async () => { + // signupMode closed: the empty-hub exception waives signup_closed, + // so the first verified, domain-allowed user still mints the root. + const result = await genesisOrJoinHubSignup( argsFor({ - api, + api: genesisApi(), tenancy: tenancy({ users: 1, tenants: 0, root: null }), displayName: "Acme", + accessPolicy: accessPolicy({ envSignupMode: "closed" }), }), ); + expect(result.kind).toBe("genesis"); }); }); diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index e3e3828ee..84a2714cd 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -268,15 +268,14 @@ describe("provisionPersonalTenantIfNeeded", () => { }), ); + expect(joins).toEqual([ + { tenantId: TENANT_ID, userId: "user_1", roleName: "member" }, + ]); expect(result).toEqual({ - kind: "provisioned", + kind: "existing-member", tenantId: TENANT_ID, tenantSlug: TENANT_SLUG, - seeded: false, }); - expect(joins).toEqual([ - { tenantId: TENANT_ID, userId: "user_1", roleName: "member" }, - ]); }); test("a slug conflict that still leaves the caller benchless is a real failure", async () => { diff --git a/scripts/e2e/cl-6324-launch-proof.ts b/scripts/e2e/cl-6324-launch-proof.ts index 3347eadc4..acc6215ea 100644 --- a/scripts/e2e/cl-6324-launch-proof.ts +++ b/scripts/e2e/cl-6324-launch-proof.ts @@ -245,7 +245,7 @@ async function main(): Promise { ); expectStatus("provision probe", res, 200); const data = res.data as { kind: string; tenantSlug: string }; - expect(data.kind).toBe("provisioned"); + expect(data.kind).toBe("existing-member"); return data; }, ); diff --git a/scripts/e2e/cl-6329-turn-swap-proof.ts b/scripts/e2e/cl-6329-turn-swap-proof.ts index fe7c43971..efce94f74 100644 --- a/scripts/e2e/cl-6329-turn-swap-proof.ts +++ b/scripts/e2e/cl-6329-turn-swap-proof.ts @@ -237,7 +237,7 @@ async function main(): Promise { ); expectStatus("provision probe", res, 200); const data = res.data as { kind: string; tenantSlug: string }; - expect(data.kind).toBe("provisioned"); + expect(data.kind).toBe("existing-member"); return data; }, ); diff --git a/scripts/e2e/cl-6451-single-run-proof.ts b/scripts/e2e/cl-6451-single-run-proof.ts index 650c0445c..8c18ac708 100644 --- a/scripts/e2e/cl-6451-single-run-proof.ts +++ b/scripts/e2e/cl-6451-single-run-proof.ts @@ -210,7 +210,7 @@ async function main(): Promise { ); expectStatus("provision probe", res, 200); const data = res.data as { kind: string; tenantSlug: string }; - expect(data.kind).toBe("provisioned"); + expect(data.kind).toBe("existing-member"); return data; }, ); diff --git a/scripts/e2e/greeting-delivery.test.ts b/scripts/e2e/greeting-delivery.test.ts index a2db0fe5d..fe83729c0 100644 --- a/scripts/e2e/greeting-delivery.test.ts +++ b/scripts/e2e/greeting-delivery.test.ts @@ -172,8 +172,7 @@ describe.skipIf(databaseUrl === undefined)( tenantSlug: string; seeded: boolean; }; - expect(data.kind).toBe("provisioned"); - expect(data.seeded).toBe(false); + expect(data.kind).toBe("existing-member"); stringField(data, "tenantSlug", "provision result"); return data; }, diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index fa6f39d9d..b91e5fe0b 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -241,9 +241,7 @@ describe.skipIf(databaseUrl === undefined)( seeded: boolean; seedSkipReason?: string; }; - expect(data.kind).toBe("provisioned"); - expect(data.seeded).toBe(false); - expect(data.seedSkipReason).toBeUndefined(); + expect(data.kind).toBe("existing-member"); stringField(data, "tenantId", "provision result"); stringField(data, "tenantSlug", "provision result"); return data; diff --git a/scripts/e2e/play.ts b/scripts/e2e/play.ts index 3fe98b82b..e7a26e5f3 100644 --- a/scripts/e2e/play.ts +++ b/scripts/e2e/play.ts @@ -199,8 +199,7 @@ async function main(): Promise { tenantSlug: string; seeded: boolean; }; - expect(data.kind).toBe("provisioned"); - expect(data.seeded).toBe(false); + expect(data.kind).toBe("existing-member"); return data; }, ); diff --git a/scripts/e2e/smoke-onboarding.test.ts b/scripts/e2e/smoke-onboarding.test.ts index d89ed57fa..f099c1358 100644 --- a/scripts/e2e/smoke-onboarding.test.ts +++ b/scripts/e2e/smoke-onboarding.test.ts @@ -6,8 +6,9 @@ // the genesis path (first signup on a truly empty hub mints the root // itself) is covered in-process by // `apps/hub/test/signup-genesis.test.ts`. This asserts the join over -// the wire: `kind: "provisioned"` naming the joined root, `seeded: -// false` (signup never seeds), and an idempotent re-provision. +// the wire: `kind: "existing-member"` naming the joined root via +// `tenantId`/`tenantSlug` (a plain member gets no wizard — CL-7584 +// owns the member UX), and an idempotent re-provision. import { describe, expect, test } from "bun:test"; @@ -102,9 +103,7 @@ describe.skipIf(databaseUrl === undefined)( seeded: boolean; seedSkipReason?: string; }; - expect(data.kind).toBe("provisioned"); - expect(data.seeded).toBe(false); - expect(data.seedSkipReason).toBeUndefined(); + expect(data.kind).toBe("existing-member"); stringField(data, "tenantId", "provision result"); stringField(data, "tenantSlug", "provision result"); return data;