diff --git a/README.md b/README.md index ad58a325d..1637c2be0 100644 --- a/README.md +++ b/README.md @@ -69,14 +69,14 @@ value at once), verifies the database in `DATABASE_URL` is reachable and actually speaks Postgres, applies any pending platform migrations, builds the web UI if it has not been built yet, and starts the hub. The hub provisions authenticated sidecars on demand using the configured backend. -Every required setting lives in `.env.example` with its expected shape, and the administrator account (`HUB_ADMIN_EMAIL` / `HUB_ADMIN_PASSWORD`, -defaulting to alice@example.com / password123 when unset) -is seeded so you can sign in immediately. - -`bun run dev` seeds that administrator account and ensures the root -tenant so you can sign in. An empty database is a valid hub: boot does -not insert agents, tools, workflows, or skills. Product state arrives -through onboarding and explicit seed callers, not production boot. +Every required setting lives in `.env.example` with its expected shape. +`bun run dev` may sign up the local administrator (`HUB_ADMIN_EMAIL` / +`HUB_ADMIN_PASSWORD`, defaulting to alice@example.com / password123 +when unset) through the same auth HTTP API the UI uses. Hub boot itself +inserts no users or tenants. An empty database is a valid hub: boot +does not insert agents, tools, workflows, or skills. Product state +arrives through onboarding and explicit seed callers, not production +boot. `ANTHROPIC_API_KEY` is the one optional line worth setting before boot — with it, the env-key auto-plant puts a real credential on the operator bench so the catalog is launchable; without it, inference waits until @@ -112,10 +112,11 @@ bun run reset `bun run reset` drops the platform database schema and removes the hub's on-disk asset directory (which also holds provisioned sidecar state). Nothing is recreated until the next `bun run dev` — that recreates the -schema and, once the hub is serving again, ensures the administrator -account and root tenant so you can sign in. It does not insert agents, -tools, workflows, or skills. Product state arrives through onboarding -and explicit seed callers. +schema. Hub boot itself inserts no users or tenants. Local `bun run +dev` may then sign up the administrator through the same auth HTTP API +the UI uses, so you can sign in. It does not insert agents, tools, +workflows, or skills. Product state arrives through onboarding and +explicit seed callers. It refuses to run against anything but a local `DATABASE_URL` (localhost, 127.0.0.1, or `::1`) — there is no override, since the schema drop is diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index 535ad78ff..08bb3ffce 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -374,10 +374,6 @@ 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 @@ -623,7 +619,7 @@ export function readHubConfig( ): HubConfig { if (env.OPERATOR_TENANT_ID !== undefined) { throw new Error( - "OPERATOR_TENANT_ID is no longer read: the hub ensures the root tenant by slug at boot. " + + "OPERATOR_TENANT_ID is no longer read: first signup mints the root tenant by slug. " + "Set WORKBENCH_DEFAULT_TENANT to your existing root tenant's slug " + '(or remove OPERATOR_TENANT_ID to keep the default slug "workbench"), then restart.', ); @@ -650,7 +646,7 @@ export function readHubConfig( .map((d) => d.trim()) .filter((d) => d.length > 0); - // One deployment fact shared by boot parenting, setup/seed, and the + // One deployment fact shared by first-signup genesis, setup/seed, and the // env-key auto-plant. WORKBENCH_DEFAULT_TENANT wins; ORG_SLUG is the // alias when that is unset. const defaultTenantSlug = diff --git a/apps/hub/src/default-tenant.test.ts b/apps/hub/src/default-tenant.test.ts deleted file mode 100644 index e32571f99..000000000 --- a/apps/hub/src/default-tenant.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring @corbits/bench's migrations test. -// Runs against its own scratch database, never the developer's or the -// walking-skeleton suite's. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import postgres from "postgres"; -import { and, eq } from "drizzle-orm"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { setupDatabase } from "../../../scripts/db-setup"; -import { dbGate } from "../../../scripts/e2e/db-gate"; -import { createDB, createPrincipalKeyStore } from "@intx/db"; -import { createNoopCredentialCipher } from "@intx/crypto"; -import { grant, principal, principalRole, role, tenant } from "@intx/db/schema"; -import { ensureDefaultTenant, type BootAdminAuth } from "./default-tenant"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_default_tenant_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const ADMIN = { email: "admin@example.com", password: "password123" }; -const ADMIN_USER_ID = "usr_boot_seed_admin"; - -/** - * Structural double of the better-auth surface boot seeding uses, - * recording what it was asked to do so tests can assert on the calls. - */ -function fakeBootAuth(opts?: { preexistingUserWithoutCredential?: boolean }) { - const calls = { userCreated: 0, accountsLinked: 0, emailVerified: false }; - const linkedProviders = new Set(); - const userExistsFromStart = opts?.preexistingUserWithoutCredential ?? false; - const auth: BootAdminAuth = { - $context: Promise.resolve({ - internalAdapter: { - findUserByEmail: async (email) => - (userExistsFromStart || calls.userCreated > 0) && - email === ADMIN.email - ? { user: { id: ADMIN_USER_ID } } - : null, - createUser: async (user) => { - calls.userCreated += 1; - calls.emailVerified = user.emailVerified; - return { id: ADMIN_USER_ID }; - }, - findAccounts: async (userId) => { - if (userId !== ADMIN_USER_ID) return []; - return [...linkedProviders].map((providerId) => ({ providerId })); - }, - linkAccount: async (account) => { - calls.accountsLinked += 1; - linkedProviders.add(account.providerId); - }, - }, - password: { hash: async (password) => `hashed(${password})` }, - }), - }; - return { auth, calls }; -} - -describeIfDb("ensureDefaultTenant", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - const db = createDB({ - host: new URL(scratchUrl).hostname, - port: Number(new URL(scratchUrl).port || 5432), - user: decodeURIComponent(new URL(scratchUrl).username), - password: decodeURIComponent(new URL(scratchUrl).password), - database: scratchDatabase, - }); - // The dev/test principal-key store: unencrypted at rest, matching the - // ALLOW_PLAINTEXT_SECRETS posture of every other suite in this package. - const principalKeyStore = createPrincipalKeyStore({ - db: db.db, - cipher: createNoopCredentialCipher(), - }); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await setupDatabase(scratchUrl); - }, 60000); - - afterAll(async () => { - await db.close(); - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - async function rowsFor(slug: string) { - return db.db.select().from(tenant).where(eq(tenant.slug, slug)); - } - - async function membershipsFor(tenantId: string) { - return db.db - .select() - .from(principal) - .where(and(eq(principal.tenantId, tenantId), eq(principal.kind, "user"))); - } - - test("creates the root tenant when absent, with a derived name, domain, and null parent", async () => { - const { auth } = fakeBootAuth(); - const id = await ensureDefaultTenant( - db.db, - auth, - ADMIN, - "acme", - principalKeyStore, - ); - expect(id).toMatch(/^tnt_/); - const rows = await rowsFor("acme"); - expect(rows).toHaveLength(1); - expect(rows[0]?.id).toBe(id); - expect(rows[0]?.name).toBe("Acme"); - expect(rows[0]?.domain).toBe("acme.localhost"); - expect(rows[0]?.parentId).toBeNull(); - }); - - test("seeds the boot admin as an owner member of a freshly created root", async () => { - const { auth, calls } = fakeBootAuth(); - const id = await ensureDefaultTenant( - db.db, - auth, - ADMIN, - "seeded-root", - principalKeyStore, - ); - - expect(calls.userCreated).toBe(1); - expect(calls.accountsLinked).toBe(1); - expect(calls.emailVerified).toBe(true); - - const memberships = await membershipsFor(id); - expect(memberships).toHaveLength(1); - expect(memberships[0]?.refId).toBe(ADMIN_USER_ID); - expect(memberships[0]?.status).toBe("active"); - - const ownerRole = await db.db - .select() - .from(role) - .where(and(eq(role.tenantId, id), eq(role.name, "owner"))); - expect(ownerRole).toHaveLength(1); - const membership = memberships[0]; - if (!membership) throw new Error("expected membership"); - const links = await db.db - .select() - .from(principalRole) - .where(eq(principalRole.principalId, membership.id)); - expect(links).toHaveLength(1); - expect(links[0]?.roleId).toBe(ownerRole[0]?.id); - - // Same grant shapes the native create-tenant route writes. - const grants = await db.db - .select() - .from(grant) - .where(eq(grant.tenantId, id)); - expect(grants).toHaveLength(5); - const ownerGrant = grants.find( - (g) => g.action === "*" && g.resource === "*", - ); - expect(ownerGrant?.roleId).toBe(ownerRole[0]?.id); - expect(ownerGrant?.effect).toBe("allow"); - expect(ownerGrant?.origin).toBe("system"); - }); - - test("re-runs are a no-op: the tenant, roles, grants, and membership are not duplicated", async () => { - const { auth } = fakeBootAuth(); - const first = await ensureDefaultTenant( - db.db, - auth, - ADMIN, - "acme", - principalKeyStore, - ); - const second = await ensureDefaultTenant( - db.db, - auth, - ADMIN, - "acme", - principalKeyStore, - ); - - expect(second).toBe(first); - expect(await rowsFor("acme")).toHaveLength(1); - expect(await membershipsFor(first)).toHaveLength(1); - expect( - await db.db.select().from(role).where(eq(role.tenantId, first)), - ).toHaveLength(3); - expect( - await db.db.select().from(grant).where(eq(grant.tenantId, first)), - ).toHaveLength(5); - }); - - test("re-selects the winner when another boot already inserted the slug (race-safe)", async () => { - // Simulates a concurrent hub winning the insert between this boot's - // select and insert: the row already exists under a different id, so - // the .onConflictDoNothing() insert is a no-op and the re-select must - // return the winning row's id. - const concurrentId = "tnt_concurrent_winner"; - await db.db - .insert(tenant) - .values({ - id: concurrentId, - name: "Bee Co", - slug: "bee-co", - domain: "bee-co.localhost", - parentId: null, - }) - .onConflictDoNothing(); - const { auth } = fakeBootAuth(); - const id = await ensureDefaultTenant( - db.db, - auth, - ADMIN, - "bee-co", - principalKeyStore, - ); - expect(id).toBe(concurrentId); - expect(await rowsFor("bee-co")).toHaveLength(1); - }); - - test("an existing root with no members yet adopts the boot admin as owner", async () => { - // A tenant created before boot seeded memberships: boot must claim it. - await db.db.insert(tenant).values({ - id: "tnt_unowned", - name: "Unowned", - slug: "unowned", - domain: "unowned.localhost", - parentId: null, - }); - const { auth } = fakeBootAuth(); - const id = await ensureDefaultTenant( - db.db, - auth, - ADMIN, - "unowned", - principalKeyStore, - ); - expect(id).toBe("tnt_unowned"); - - const memberships = await membershipsFor(id); - expect(memberships).toHaveLength(1); - expect(memberships[0]?.refId).toBe(ADMIN_USER_ID); - }); - - test("a tenant the admin already belongs to is left untouched (any role)", async () => { - await db.db.insert(tenant).values({ - id: "tnt_already", - name: "Already", - slug: "already", - domain: "already.localhost", - parentId: null, - }); - await db.db.insert(role).values({ - id: "rol_already_member", - tenantId: "tnt_already", - name: "member", - isSystem: true, - }); - await db.db.insert(principal).values({ - id: "prl_already_admin", - tenantId: "tnt_already", - kind: "user", - refId: ADMIN_USER_ID, - status: "active", - }); - // give the admin the plain member role, not owner - await db.db.insert(principalRole).values({ - principalId: "prl_already_admin", - roleId: "rol_already_member", - }); - - const { auth } = fakeBootAuth(); - const id = await ensureDefaultTenant( - db.db, - auth, - ADMIN, - "already", - principalKeyStore, - ); - expect(id).toBe("tnt_already"); - - const memberships = await membershipsFor(id); - expect(memberships).toHaveLength(1); - const membership = memberships[0]; - if (!membership) throw new Error("expected membership"); - const links = await db.db - .select() - .from(principalRole) - .where(eq(principalRole.principalId, membership.id)); - expect(links).toHaveLength(1); - expect(links[0]?.roleId).toBe("rol_already_member"); - }); - - test("a tenant owned by someone else is left alone — the admin is not added", async () => { - await db.db.insert(tenant).values({ - id: "tnt_foreign", - name: "Foreign", - slug: "foreign", - domain: "foreign.localhost", - parentId: null, - }); - await db.db.insert(role).values({ - id: "rol_foreign_owner", - tenantId: "tnt_foreign", - name: "owner", - isSystem: true, - }); - await db.db.insert(principal).values({ - id: "prl_foreign_owner", - tenantId: "tnt_foreign", - kind: "user", - refId: "usr_someone_else", - status: "active", - }); - - const { auth } = fakeBootAuth(); - const id = await ensureDefaultTenant( - db.db, - auth, - ADMIN, - "foreign", - principalKeyStore, - ); - expect(id).toBe("tnt_foreign"); - - const memberships = await membershipsFor(id); - expect(memberships).toHaveLength(1); - expect(memberships[0]?.refId).toBe("usr_someone_else"); - }); - - test("an existing admin user without a credential account gets one linked", async () => { - // Partial failure: createUser committed, linkAccount threw. Re-boot - // must still ensure a credential so sign-in works. - const { auth, calls } = fakeBootAuth({ - preexistingUserWithoutCredential: true, - }); - await ensureDefaultTenant( - db.db, - auth, - ADMIN, - "cred-repair", - principalKeyStore, - ); - - expect(calls.userCreated).toBe(0); - expect(calls.accountsLinked).toBe(1); - }); - - test("an admin principal with no role gets the owner role attached on re-boot", async () => { - // Partial failure: principal insert committed, principalRole threw. - await db.db.insert(tenant).values({ - id: "tnt_roleless", - name: "Roleless", - slug: "roleless", - domain: "roleless.localhost", - parentId: null, - }); - await db.db.insert(principal).values({ - id: "prl_roleless_admin", - tenantId: "tnt_roleless", - kind: "user", - refId: ADMIN_USER_ID, - status: "active", - }); - - const { auth } = fakeBootAuth(); - const id = await ensureDefaultTenant( - db.db, - auth, - ADMIN, - "roleless", - principalKeyStore, - ); - expect(id).toBe("tnt_roleless"); - - const memberships = await membershipsFor(id); - expect(memberships).toHaveLength(1); - const membership = memberships[0]; - if (!membership) throw new Error("expected membership"); - - const ownerRole = await db.db - .select() - .from(role) - .where(and(eq(role.tenantId, id), eq(role.name, "owner"))); - expect(ownerRole).toHaveLength(1); - - const links = await db.db - .select() - .from(principalRole) - .where(eq(principalRole.principalId, membership.id)); - expect(links).toHaveLength(1); - expect(links[0]?.roleId).toBe(ownerRole[0]?.id); - }); -}); diff --git a/apps/hub/src/default-tenant.ts b/apps/hub/src/default-tenant.ts deleted file mode 100644 index e2c2ba425..000000000 --- a/apps/hub/src/default-tenant.ts +++ /dev/null @@ -1,385 +0,0 @@ -// The hub's root tenant, ensured at boot instead of configured through -// the environment. Every self-served personal bench parents under this -// tenant, so it must exist before the first sign-in can provision one — -// boot is the one moment the hub can guarantee that ordering. The slug -// comes from WORKBENCH_DEFAULT_TENANT (default "workbench", see -// config.ts); the row's id becomes the runtime operatorTenantId handed -// to onboarding and the tenant-create guard. -// -// Boot also finishes setup itself: it seeds the operator's admin -// account (HUB_ADMIN_EMAIL/PASSWORD — the same identity `workbench -// setup` signs in as) and makes that account the root tenant's owner. -// Without the membership the root would have no members at all: -// `workbench setup` could never adopt it through its principals scan, -// its access-policy row would have no editor, and nothing could invite -// a second member. Every step is idempotent, so re-running boot (and -// every restart) is a no-op. - -import { and, eq } from "drizzle-orm"; -import { generateId } from "@intx/hub-common"; -import { - createPrincipalStore, - type DB, - type PrincipalKeyStore, -} from "@intx/db"; -import { grant, principal, principalRole, role, tenant } from "@intx/db/schema"; - -/** - * The better-auth surface boot seeding needs, named structurally so the - * seeding is testable without standing up a whole auth instance. - */ -export type BootAdminAuth = { - $context: Promise<{ - internalAdapter: { - findUserByEmail(email: string): Promise<{ user: { id: string } } | null>; - createUser(user: { - email: string; - name: string; - emailVerified: boolean; - }): Promise<{ id: string }>; - findAccounts(userId: string): Promise<{ providerId: string }[]>; - linkAccount(account: { - userId: string; - providerId: string; - accountId: string; - password: string; - }): Promise; - }; - password: { hash(password: string): Promise }; - }>; -}; - -const SYSTEM_ROLES = ["owner", "admin", "member"] as const; -type SystemRoleName = (typeof SYSTEM_ROLES)[number]; - -// Same shapes the native create-tenant route writes -// (vendor/intx/hub-api/src/routes/tenants.ts): a role row per system -// role, then role-targeted allow grants. -const SYSTEM_ROLE_GRANTS: Record< - SystemRoleName, - { resource: string; action: string }[] -> = { - owner: [{ resource: "*", action: "*" }], - admin: [ - { resource: "*", action: "read" }, - { resource: "*", action: "create" }, - { resource: "*", action: "manage" }, - ], - member: [{ resource: "*", action: "read" }], -}; - -function tenantNameFromSlug(slug: string): string { - return slug - .split("-") - .filter((part) => part !== "") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -async function linkCredentialAccount( - context: Awaited, - userId: string, - password: string, -): Promise { - const passwordHash = await context.password.hash(password); - await context.internalAdapter.linkAccount({ - userId, - providerId: "credential", - accountId: userId, - password: passwordHash, - }); -} - -async function ensureAdminUser( - auth: BootAdminAuth, - admin: { email: string; password: string }, -): Promise { - const context = await auth.$context; - const existing = await context.internalAdapter.findUserByEmail(admin.email); - if (existing !== null) { - // Partial-failure recovery: createUser may have committed while - // linkAccount threw. Re-boot must still ensure a credential account - // exists — otherwise the hub comes up and the admin cannot sign in. - const accounts = await context.internalAdapter.findAccounts( - existing.user.id, - ); - const hasCredential = accounts.some( - (account) => account.providerId === "credential", - ); - if (!hasCredential) { - await linkCredentialAccount(context, existing.user.id, admin.password); - } - return existing.user.id; - } - - const user = await context.internalAdapter.createUser({ - email: admin.email, - name: admin.email.split("@")[0] ?? admin.email, - // No mailer exists anywhere in this stack (see index.ts's - // allowUnverifiedEmails note), and this address is operator- - // configured rather than self-claimed — verifying it at the source - // is the honest reading of "verified", not a bypass. - emailVerified: true, - }); - await linkCredentialAccount(context, user.id, admin.password); - return user.id; -} - -async function ensureSystemRole( - db: DB["db"], - tenantId: string, - roleName: SystemRoleName, -): Promise { - const existing = await db - .select({ id: role.id }) - .from(role) - .where(and(eq(role.tenantId, tenantId), eq(role.name, roleName))); - if (existing.length > 0) { - const row = existing[0]; - if (!row) { - throw new Error( - "ensureSystemRole: existing.length > 0 but existing[0] is missing", - ); - } - return row.id; - } - - const now = new Date(); - const [inserted] = await db - .insert(role) - .values({ - id: generateId("role"), - tenantId, - name: roleName, - description: `System ${roleName} role`, - isSystem: true, - createdAt: now, - updatedAt: now, - }) - .returning({ id: role.id }); - if (!inserted) { - throw new Error( - "ensureSystemRole: insert returned no row; the role table is in an unexpected state", - ); - } - return inserted.id; -} - -async function ensureSystemRoleGrants( - db: DB["db"], - tenantId: string, - roleName: SystemRoleName, - roleId: string, -): Promise { - for (const shape of SYSTEM_ROLE_GRANTS[roleName]) { - const existing = await db - .select({ id: grant.id }) - .from(grant) - .where( - and( - eq(grant.tenantId, tenantId), - eq(grant.roleId, roleId), - eq(grant.resource, shape.resource), - eq(grant.action, shape.action), - eq(grant.effect, "allow"), - eq(grant.origin, "system"), - ), - ); - if (existing.length > 0) continue; - - const now = new Date(); - await db.insert(grant).values({ - id: generateId("grant"), - tenantId, - roleId, - resource: shape.resource, - action: shape.action, - effect: "allow", - origin: "system", - createdAt: now, - updatedAt: now, - }); - } -} - -/** - * Make `adminUserId` the root tenant's owner. A tenant the admin already - * belongs to with any role, or that already belongs to someone else, is - * left exactly as found — boot never rearranges intentional memberships. - * A principal with no role at all (create-then-link partial failure) is - * repaired by attaching the owner role. - */ -async function ensureOwnerMembership( - db: DB["db"], - tenantId: string, - adminUserId: string, - ownerRoleId: string, - principalKeyStore: PrincipalKeyStore, -): Promise { - const adminMemberships = await db - .select({ id: principal.id }) - .from(principal) - .where( - and( - eq(principal.tenantId, tenantId), - eq(principal.kind, "user"), - eq(principal.refId, adminUserId), - ), - ); - if (adminMemberships.length > 0) { - const adminPrincipal = adminMemberships[0]; - if (!adminPrincipal) { - throw new Error( - "ensureOwnerMembership: adminMemberships.length > 0 but [0] is missing", - ); - } - const existingRoles = await db - .select({ roleId: principalRole.roleId }) - .from(principalRole) - .where(eq(principalRole.principalId, adminPrincipal.id)); - if (existingRoles.length === 0) { - await db - .insert(principalRole) - .values({ - principalId: adminPrincipal.id, - roleId: ownerRoleId, - createdAt: new Date(), - }) - .onConflictDoNothing(); - } - return; - } - - const userMemberships = await db - .select({ id: principal.id }) - .from(principal) - .where(and(eq(principal.tenantId, tenantId), eq(principal.kind, "user"))); - if (userMemberships.length > 0) return; - - const now = new Date(); - await createPrincipalStore(db, principalKeyStore).createIfAbsent({ - id: generateId("principal"), - tenantId, - kind: "user", - refId: adminUserId, - status: "active", - createdAt: now, - updatedAt: now, - }); - - const created = await db - .select({ id: principal.id }) - .from(principal) - .where( - and( - eq(principal.tenantId, tenantId), - eq(principal.kind, "user"), - eq(principal.refId, adminUserId), - ), - ); - if (created.length === 0) { - throw new Error( - "ensureDefaultTenant: owner-membership insert was a no-op but the " + - "principal row cannot be read back; the principal table is in an " + - "unexpected state", - ); - } - const createdPrincipal = created[0]; - if (!createdPrincipal) { - throw new Error( - "ensureDefaultTenant: created.length > 0 but created[0] is missing", - ); - } - - await db - .insert(principalRole) - .values({ - principalId: createdPrincipal.id, - roleId: ownerRoleId, - createdAt: now, - }) - .onConflictDoNothing(); -} - -/** - * Return the id of the root tenant for `slug`, creating it when absent, - * with the boot admin seeded as its owner. Race-safe: a concurrent boot - * (or a previous boot) may insert the same slug between this boot's - * select and insert, so the insert is `.onConflictDoNothing()` and the - * post-insert re-select returns the winning row's id — every caller - * converges on one tenant. Failure fails the boot loudly. - */ -export async function ensureDefaultTenant( - db: DB["db"], - auth: BootAdminAuth, - admin: { email: string; password: string }, - slug: string, - principalKeyStore: PrincipalKeyStore, -): Promise { - // The membership references the admin user, so the user exists first. - const adminUserId = await ensureAdminUser(auth, admin); - - const existing = await db - .select({ id: tenant.id }) - .from(tenant) - .where(eq(tenant.slug, slug)); - let tenantId: string; - if (existing.length > 0) { - const row = existing[0]; - if (!row) { - throw new Error( - "ensureDefaultTenant: existing.length > 0 but existing[0] is missing", - ); - } - tenantId = row.id; - } else { - await db - .insert(tenant) - .values({ - id: generateId("tenant"), - name: tenantNameFromSlug(slug), - slug, - domain: `${slug}.localhost`, - parentId: null, - }) - .onConflictDoNothing(); - - const winner = await db - .select({ id: tenant.id }) - .from(tenant) - .where(eq(tenant.slug, slug)); - if (winner.length === 0) { - throw new Error( - `ensureDefaultTenant: insert of root tenant ${JSON.stringify(slug)} ` + - "was a no-op but the row cannot be read back; the tenant table is " + - "in an unexpected state", - ); - } - const winnerRow = winner[0]; - if (!winnerRow) { - throw new Error( - "ensureDefaultTenant: winner.length > 0 but winner[0] is missing", - ); - } - tenantId = winnerRow.id; - } - - const roleIds: Record = { - owner: await ensureSystemRole(db, tenantId, "owner"), - admin: await ensureSystemRole(db, tenantId, "admin"), - member: await ensureSystemRole(db, tenantId, "member"), - }; - for (const roleName of SYSTEM_ROLES) { - await ensureSystemRoleGrants(db, tenantId, roleName, roleIds[roleName]); - } - - await ensureOwnerMembership( - db, - tenantId, - adminUserId, - roleIds.owner, - principalKeyStore, - ); - - return tenantId; -} diff --git a/apps/hub/src/env-credential-plant.ts b/apps/hub/src/env-credential-plant.ts index 31daf2e9e..19bce4778 100644 --- a/apps/hub/src/env-credential-plant.ts +++ b/apps/hub/src/env-credential-plant.ts @@ -9,7 +9,7 @@ // (HUB_ADMIN_EMAIL/PASSWORD, defaulted the same way those commands // default them) and look up the bench named ORG_SLUG among that // account's own memberships. On a virgin database neither the admin -// account nor that bench exist yet — `workbench setup` (or `bun run +// account nor that bench exist yet — first signup (or `bun run // dev`'s own account seeding) creates them, often as a separate // process, well after this hub has already started serving. Rather // than fail hub boot over a bench that legitimately doesn't exist yet, diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 29b2e5d77..71a978e96 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -162,7 +162,6 @@ import { createWorkflowCatalogAdminRoutes } from "@corbits/catalog-tools/routes" import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes"; import { generateId } from "@intx/hub-common"; -import { ensureDefaultTenant } from "./default-tenant"; import { createHubSignupTenancy } from "./signup-tenancy"; import { createInMemoryMailboxEventBus, @@ -738,25 +737,6 @@ export async function createHub(config: HubConfig) { } : undefined, }); - // The root tenant must exist before the first sign-in can provision a - // personal bench under it; boot is the one moment the hub can - // guarantee that ordering. Boot seeds the admin account and its owner - // 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. 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. @@ -3608,8 +3588,6 @@ export async function createHub(config: HubConfig) { : undefined; }, }; - if (operatorTenantId !== undefined) - guardDeps.operatorTenantId = operatorTenantId; const guardedApp = guardedHubApp(app, guardDeps); const inFlight = createInFlightRequestTracker(); const servingApp = withInFlightRequestTracking(guardedApp, inFlight); diff --git a/apps/hub/test/boot-does-not-seed.test.ts b/apps/hub/test/boot-does-not-seed.test.ts index 00d2348af..895356121 100644 --- a/apps/hub/test/boot-does-not-seed.test.ts +++ b/apps/hub/test/boot-does-not-seed.test.ts @@ -1,12 +1,19 @@ -// Process-boot proof that hub production boot does not insert product -// state. createHub() never seeded; only `import.meta.main` did, so this -// suite spawns the hub as a real process via startHub rather than -// composing in-process. +// Process-boot and createHub proof that hub production boot does not +// mint a root tenant or an admin account. An empty database is a valid +// hub: /status, health, and auth mechanics serve with zero tenant rows. +// First signup (signup-genesis.test.ts) is the 0→1 path. // // DB-gated: boots against its own scratch database so a reachable // DATABASE_URL is required and the suite skips without one. -import { expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { afterAll, expect, test } from "bun:test"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import postgres from "postgres"; @@ -16,30 +23,38 @@ import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url.ts"; import { api, createCleanupHarness, - expectStatus, freePort, hop, startHub, } from "../../../scripts/e2e/harness.ts"; +import type { HubConfig } from "../src/config.ts"; +import { createHub } from "../src/index.ts"; const databaseUrl = e2eDatabaseUrl(); const describeIfDb = dbGate(databaseUrl, import.meta.path); const { tempDir, track } = createCleanupHarness(); -const ADMIN = { email: "alice@example.com", password: "password123" }; +const ALICE = { email: "alice@example.com", password: "password123" }; + +const closers: (() => Promise)[] = []; +afterAll(async () => { + let closer: (() => Promise) | undefined; + while ((closer = closers.pop()) !== undefined) await closer(); +}); -function scratchUrl(): string { +function scratchUrl(suffix: string): string { const url = new URL(databaseUrl ?? "postgres://localhost:5432/unused"); const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_boot_does_not_seed`; + url.pathname = `/${database}_${suffix}`; return url.toString(); } async function withScratchDatabase( + suffix: string, run: (url: string) => Promise, ): Promise { - const scratchUrlValue = scratchUrl(); + const scratchUrlValue = scratchUrl(suffix); const maintenanceUrl = new URL(scratchUrlValue); maintenanceUrl.pathname = "/postgres"; const scratchDatabase = new URL(scratchUrlValue).pathname.replace(/^\//, ""); @@ -71,76 +86,22 @@ async function withScratchDatabase( } } -function namedAssets(data: unknown): { name: string }[] { - if (!Array.isArray(data)) { - throw new Error(`expected an asset list, got ${JSON.stringify(data)}`); - } - return data.map((row) => { - if ( - typeof row !== "object" || - row === null || - typeof row.name !== "string" - ) { - throw new Error( - `expected asset rows with names, got ${JSON.stringify(data)}`, - ); - } - return { name: row.name }; - }); -} - -function skillNames(data: unknown): string[] { - if (typeof data !== "object" || data === null || !("skills" in data)) { - throw new Error(`expected { skills }, got ${JSON.stringify(data)}`); - } - const skills = (data as { skills: unknown }).skills; - if (!Array.isArray(skills)) { - throw new Error(`expected skills array, got ${JSON.stringify(data)}`); - } - return skills.map((row) => { - if ( - typeof row !== "object" || - row === null || - typeof row.name !== "string" - ) { +async function countTenants(url: string): Promise { + const sql = postgres(url, { max: 1, onnotice: () => undefined }); + try { + const rows = await sql<{ count: number }[]>` + select count(*)::int as count from tenant + `; + const count = rows[0]?.count; + if (typeof count !== "number") { throw new Error( - `expected skill rows with names, got ${JSON.stringify(data)}`, + `tenant count: expected a number, got ${JSON.stringify(rows)}`, ); } - return row.name; - }); -} - -function asList(data: unknown, what: string): unknown[] { - if (!Array.isArray(data)) { - throw new Error(`${what}: expected a list, got ${JSON.stringify(data)}`); - } - return data; -} - -function tenantIdFromPrincipals(data: unknown): string { - if (typeof data !== "object" || data === null || !("data" in data)) { - throw new Error( - `principals: expected { data }, got ${JSON.stringify(data)}`, - ); - } - const rows = (data as { data: unknown }).data; - if (!Array.isArray(rows) || rows.length === 0) { - throw new Error( - `principals: expected at least one membership, got ${JSON.stringify(data)}`, - ); - } - const first = rows[0]; - if ( - typeof first !== "object" || - first === null || - typeof first.tenantId !== "string" - ) { - throw new Error( - `principals: missing tenantId, got ${JSON.stringify(data)}`, - ); + return count; + } finally { + await sql.end(); } - return first.tenantId; } test("process boot source does not mention the deleted boot seeder", () => { @@ -150,11 +111,14 @@ test("process boot source does not mention the deleted boot seeder", () => { ); expect(indexSource).not.toContain("runSystem" + "Seed"); expect(indexSource).not.toContain("system" + "-seed"); + expect(indexSource).not.toContain("ensureDefault" + "Tenant"); + expect(indexSource).not.toContain("default" + "-tenant"); + expect(indexSource).not.toContain("skipEnsureDefault" + "Tenant"); }); -describeIfDb("hub process boot does not seed product state", () => { - test("process boot inserts no corbits-tools, assistant, skills, or workflow deployments", async () => { - await withScratchDatabase(async (url) => { +describeIfDb("hub process boot does not mint a root tenant", () => { + test("process boot serves /status with an empty tenant table and no boot admin", async () => { + await withScratchDatabase("boot_does_not_seed", async (url) => { const dataDir = await tempDir("hub-boot-does-not-seed-"); const hub = await hop("hub process boot", () => startHub({ @@ -168,81 +132,76 @@ describeIfDb("hub process boot does not seed product state", () => { ); track(hub); - const cookies = await hop("sign in as the boot admin", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-in/email", { - email: ADMIN.email, - password: ADMIN.password, - }); - expectStatus("sign-in", res, 200); - if (res.cookies.length === 0) { - throw new Error("sign-in returned no session cookie"); - } - return res.cookies; - }); - - const tenantId = await hop("resolve the root tenant", async () => { - const res = await api( - hub.baseUrl, - "GET", - "/api/me/principals", - undefined, - cookies, - ); - expectStatus("principals", res, 200); - return tenantIdFromPrincipals(res.data); - }); - - await hop("no corbits-tools package-registry", async () => { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenantId}/assets?kind=package-registry`, - undefined, - cookies, - ); - expectStatus("list package-registry assets", res, 200); - expect(namedAssets(res.data).map((a) => a.name)).not.toContain( - "corbits-tools", - ); + const status = await hop("/status", async () => { + const res = await fetch(`${hub.baseUrl}/status`); + expect(res.status).toBe(200); + return res; }); + expect(await status.json()).toEqual({ status: "ok" }); - await hop("no assistant workflow asset", async () => { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenantId}/assets?kind=workflow`, - undefined, - cookies, - ); - expectStatus("list workflow assets", res, 200); - expect(namedAssets(res.data).map((a) => a.name)).not.toContain( - "assistant", - ); - }); + expect(await countTenants(url)).toBe(0); - await hop("no skills", async () => { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenantId}/skills`, - undefined, - cookies, - ); - expectStatus("list skills", res, 200); - expect(skillNames(res.data)).toEqual([]); - }); + const signIn = await hop( + "sign-in as the former boot admin is not 200", + async () => + api(hub.baseUrl, "POST", "/api/auth/sign-in/email", { + email: ALICE.email, + password: ALICE.password, + }), + ); + expect(signIn.status).not.toBe(200); + }); + }, 60_000); +}); - await hop("no workflow deployments", async () => { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenantId}/workflows/deployments`, - undefined, - cookies, - ); - expectStatus("list workflow deployments", res, 200); - expect(asList(res.data, "deployments")).toEqual([]); - }); +describeIfDb("createHub on a scratch database inserts no tenant", () => { + test("createHub serves health and auth with zero tenant rows", async () => { + await withScratchDatabase("create_hub_empty", async (url) => { + const root = mkdtempSync(path.join(tmpdir(), "hub-createhub-empty-")); + const staticDir = path.join(root, "static"); + mkdirSync(staticDir, { recursive: true }); + writeFileSync(path.join(staticDir, "index.html"), "shell"); + mkdirSync(path.join(root, "data"), { recursive: true }); + + const config: HubConfig = { + databaseUrl: url, + baseUrl: "http://localhost:3000", + 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: "closed", + allowedEmailDomains: [], + allowPlaintextSecrets: true, + allowUnverifiedEmails: true, + sidecarProvisioners: [], + envProviderKeys: {}, + envProviderBaseUrls: {}, + envCredentialPlantAdmin: { + email: "alice@example.com", + password: "password123", + orgSlug: "workbench", + }, + chatIdleReapMs: 30 * 60_000, + }; + const hub = await createHub(config); + const stop = async () => { + await hub.close(); + rmSync(root, { recursive: true, force: true }); + }; + closers.push(stop); + + const status = await hub.app.request("/status"); + expect(status.status).toBe(200); + expect(await status.json()).toEqual({ status: "ok" }); + + const me = await hub.app.request("/api/me/principals"); + expect(me.status).toBe(401); + + expect(await countTenants(url)).toBe(0); }); }, 60_000); }); diff --git a/apps/hub/test/signup-genesis.test.ts b/apps/hub/test/signup-genesis.test.ts index c6623fb5c..371647551 100644 --- a/apps/hub/test/signup-genesis.test.ts +++ b/apps/hub/test/signup-genesis.test.ts @@ -1,12 +1,12 @@ -// 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. +// CL-7578 end-to-end proof of the 0→1 contract: a hub booted on an +// empty database 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. +// database, 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"; @@ -123,7 +123,6 @@ async function bootEmptyHub(args: { orgSlug: "workbench", }, chatIdleReapMs: 30 * 60_000, - skipEnsureDefaultTenant: true, }; const hub = await createHub(config); server.reload({ fetch: hub.app.fetch }); @@ -279,7 +278,7 @@ describeIfDb("signup genesis (CL-7578)", () => { ); expect(await deployments.json()).toEqual([]); }); - }); + }, 30_000); test("the second signup on an open hub joins the existing root as member, minting nothing", async () => { const scratchUrl = scratchUrlFor("open"); @@ -329,7 +328,7 @@ describeIfDb("signup genesis (CL-7578)", () => { }), ).toEqual(["member"]); }); - }); + }, 30_000); test("an operator-removed member cannot self-rejoin on a closed hub", async () => { const scratchUrl = scratchUrlFor("rejoin"); @@ -426,5 +425,5 @@ describeIfDb("signup genesis (CL-7578)", () => { .limit(1); expect(bobAfter).toBeUndefined(); }); - }); + }, 30_000); }); diff --git a/docs/TENANCY.md b/docs/TENANCY.md index 0cc9ebf9d..7906ddcbb 100644 --- a/docs/TENANCY.md +++ b/docs/TENANCY.md @@ -26,9 +26,10 @@ walks the chain on every read. ### Root tenant slug (one deployment fact) -The hub ensures a root tenant at boot by slug. An empty database is a -valid hub: that root has an admin owner, not agents, tools, workflows, or -skills. The same slug is the env-key auto-plant resolve: +The default tenant slug is a deployment fact, not a boot insert. An +empty database is a valid hub: boot mints no root. First signup +creates the root with this slug, and the env-key auto-plant resolves +the same slug once that tenant exists: 1. `WORKBENCH_DEFAULT_TENANT` if set 2. else `ORG_SLUG` (alias) @@ -36,13 +37,13 @@ skills. The same slug is the env-key auto-plant resolve: Set only one. Custom-slug upgrades whose existing root is not `workbench` must set `WORKBENCH_DEFAULT_TENANT=` -before restarting — otherwise boot creates a new empty `workbench` -root and personal-bench parenting moves under it. Leftover +before the next genesis or plant — otherwise first signup mints a +`workbench` root. Leftover `OPERATOR_TENANT_ID` is no longer read: `readHubConfig` fails loudly and tells the operator to set `WORKBENCH_DEFAULT_TENANT` (or remove the stale key for the default slug). -A freshly ensured root has no `access_policy` row yet, so signup falls +A freshly minted root has no `access_policy` row yet, so signup falls back to `WORKBENCH_SIGNUP` until Settings → People → "Who can join" writes one. This cutover does not migrate policy rows from a previous operator tenant. @@ -50,8 +51,8 @@ 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: +default after boot, which never mints a root — 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 diff --git a/scripts/e2e/cl-6324-launch-proof.ts b/scripts/e2e/cl-6324-launch-proof.ts index acc6215ea..f376fbcb8 100644 --- a/scripts/e2e/cl-6324-launch-proof.ts +++ b/scripts/e2e/cl-6324-launch-proof.ts @@ -37,7 +37,6 @@ 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"; @@ -229,12 +228,48 @@ async function main(): Promise { const hubApi: ApiCall = createHubAPI(hub.baseUrl); const pushWorkflow = createGitWorkflowPusher(); + const admin = await hop("alice genesis sign-up", async () => { + const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { + name: "Alice", + email: "alice@example.com", + password: "password123", + }); + expectStatus("alice sign-up", res, 200); + if (res.cookies.length === 0) { + throw new Error("alice sign-up returned no session cookie"); + } + const userId = stringField( + (res.data as { user: unknown }).user, + "id", + "alice sign-up user field", + ); + const probe = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + undefined, + res.cookies, + ); + expectStatus("alice genesis probe", probe, 200); + expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); + const minted = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + { name: "Workbench" }, + res.cookies, + ); + expectStatus("alice genesis provision", minted, 200); + expect((minted.data as { kind: string }).kind).toBe("provisioned"); + return { cookies: res.cookies, userId }; + }); + const user = await hop("sign up", async () => signUp(hub.baseUrl, "CL-6324 Proof"), ); const provisioned = await hop( - "a membership probe joins the boot root", + "a membership probe joins the genesis root", async () => { const res = await api( hub.baseUrl, @@ -251,15 +286,7 @@ async function main(): Promise { ); // 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 }; - }); - + // below runs as alice, the genesis owner. const tenant = await hop("joined root resolves", async () => { const found = await findPersonalTenant( hubApi, diff --git a/scripts/e2e/cl-6329-turn-swap-proof.ts b/scripts/e2e/cl-6329-turn-swap-proof.ts index efce94f74..badbad793 100644 --- a/scripts/e2e/cl-6329-turn-swap-proof.ts +++ b/scripts/e2e/cl-6329-turn-swap-proof.ts @@ -34,7 +34,6 @@ import { } from "../../packages/seeding/src/index.ts"; import { createHubAPI, - signIn, type ApiCall, } from "../../packages/hub-api-client/src/index.ts"; import { @@ -221,12 +220,48 @@ async function main(): Promise { const hubApi: ApiCall = createHubAPI(hub.baseUrl); const pushWorkflow = createGitWorkflowPusher(); + const admin = await hop("alice genesis sign-up", async () => { + const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { + name: "Alice", + email: "alice@example.com", + password: "password123", + }); + expectStatus("alice sign-up", res, 200); + if (res.cookies.length === 0) { + throw new Error("alice sign-up returned no session cookie"); + } + const userId = stringField( + (res.data as { user: unknown }).user, + "id", + "alice sign-up user field", + ); + const probe = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + undefined, + res.cookies, + ); + expectStatus("alice genesis probe", probe, 200); + expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); + const minted = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + { name: "Workbench" }, + res.cookies, + ); + expectStatus("alice genesis provision", minted, 200); + expect((minted.data as { kind: string }).kind).toBe("provisioned"); + return { cookies: res.cookies, userId }; + }); + const user = await hop("sign up", async () => signUp(hub.baseUrl, "CL-6329 Proof"), ); const provisioned = await hop( - "a membership probe joins the boot root", + "a membership probe joins the genesis root", async () => { const res = await api( hub.baseUrl, @@ -243,15 +278,7 @@ async function main(): Promise { ); // 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 }; - }); - + // below runs as alice, the genesis owner. const tenant = await hop("joined root resolves", async () => { const found = await findPersonalTenant( hubApi, diff --git a/scripts/e2e/cl-6451-single-run-proof.ts b/scripts/e2e/cl-6451-single-run-proof.ts index 8c18ac708..7f80100d3 100644 --- a/scripts/e2e/cl-6451-single-run-proof.ts +++ b/scripts/e2e/cl-6451-single-run-proof.ts @@ -40,7 +40,6 @@ import { } from "../../packages/seeding/src/index.ts"; import { createHubAPI, - signIn, type ApiCall, } from "../../packages/hub-api-client/src/index.ts"; import { @@ -194,12 +193,48 @@ async function main(): Promise { const hubApi: ApiCall = createHubAPI(hub.baseUrl); const pushWorkflow = createGitWorkflowPusher(); + const admin = await hop("alice genesis sign-up", async () => { + const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { + name: "Alice", + email: "alice@example.com", + password: "password123", + }); + expectStatus("alice sign-up", res, 200); + if (res.cookies.length === 0) { + throw new Error("alice sign-up returned no session cookie"); + } + const userId = stringField( + (res.data as { user: unknown }).user, + "id", + "alice sign-up user field", + ); + const probe = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + undefined, + res.cookies, + ); + expectStatus("alice genesis probe", probe, 200); + expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); + const minted = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + { name: "Workbench" }, + res.cookies, + ); + expectStatus("alice genesis provision", minted, 200); + expect((minted.data as { kind: string }).kind).toBe("provisioned"); + return { cookies: res.cookies, userId }; + }); + const user = await hop("sign up", async () => signUp(hub.baseUrl, "CL-6451 Proof"), ); const provisioned = await hop( - "a membership probe joins the boot root", + "a membership probe joins the genesis root", async () => { const res = await api( hub.baseUrl, @@ -216,15 +251,7 @@ async function main(): Promise { ); // 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 }; - }); - + // below runs as alice, the genesis owner. const tenant = await hop("joined root resolves", async () => { const found = await findPersonalTenant( hubApi, diff --git a/scripts/e2e/greeting-delivery.test.ts b/scripts/e2e/greeting-delivery.test.ts index fe83729c0..6f47683bd 100644 --- a/scripts/e2e/greeting-delivery.test.ts +++ b/scripts/e2e/greeting-delivery.test.ts @@ -8,8 +8,9 @@ // 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 (signup joins the boot root as -// a member; the owner connects a credential through the key path) +// Mirrors `local-rip.test.ts`'s phase A (alice signs up first as +// genesis owner; a tester then joins 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 @@ -26,7 +27,6 @@ import { } from "../../packages/seeding/src/index.ts"; import { createHubAPI, - signIn, type ApiCall, } from "../../packages/hub-api-client/src/index.ts"; import { @@ -148,16 +148,51 @@ describe.skipIf(databaseUrl === undefined)( const hubApi: ApiCall = createHubAPI(hub.baseUrl); + const admin = await hop("alice genesis sign-up", async () => { + const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { + name: "Alice", + email: "alice@example.com", + password: "password123", + }); + expectStatus("alice sign-up", res, 200); + if (res.cookies.length === 0) { + throw new Error("alice sign-up returned no session cookie"); + } + const userId = stringField( + (res.data as { user: unknown }).user, + "id", + "alice sign-up user field", + ); + const probe = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + undefined, + res.cookies, + ); + expectStatus("alice genesis probe", probe, 200); + expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); + const minted = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + { name: "Workbench" }, + res.cookies, + ); + expectStatus("alice genesis provision", minted, 200); + expect((minted.data as { kind: string }).kind).toBe("provisioned"); + return { cookies: res.cookies, userId }; + }); + const user = await hop("sign-up", () => 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`. + // Under the CL-7578 genesis-or-join contract the tester joins the + // genesis root as a plain member — the genesis path is covered + // in-process by `apps/hub/test/signup-genesis.test.ts`. const provisioned = await hop( - "a membership probe joins the boot root as a member", + "a membership probe joins the genesis root as a member", async () => { const res = await api( hub.baseUrl, @@ -179,17 +214,8 @@ describe.skipIf(databaseUrl === undefined)( ); // 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 }; - }); - + // leg below — seeding, chat mint, turns — runs as alice, the + // genesis owner. const tenant = await hop( "the joined root resolves through findPersonalTenant", async () => { diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index b91e5fe0b..41ee29dd7 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -3,19 +3,19 @@ // provider. One sequential scenario against a real hub, a real // sidecar, and a real Postgres. // -// Phase A (onboard → connect): closed-by-default signup is respected -// → 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. +// Phase A (onboard → connect): alice signs up first as genesis owner +// → a tester joins the genesis root as a plain member (the genesis +// path — first signup on a truly empty hub mints the root — is also +// covered in-process by `apps/hub/test/signup-genesis.test.ts`) → +// occupied closed signup is refused → the root's owner (alice) +// 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 alice. // // Until CL-6057, this suite documented a real platform gap instead of // hiding it: the "assistant" default workflow pins @@ -23,7 +23,7 @@ // 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 -// connect flow runs on the boot-ensured root itself, so an explicit +// connect flow runs on the genesis root itself, so an explicit // `publishCorbitsToolsRegistry` hop onto the root stands in for // setup, then `ensureSeeded` deploys without packing. // @@ -68,7 +68,6 @@ import { import { createHubAPI, parseAs, - signIn, type ApiCall, } from "../../packages/hub-api-client/src/index.ts"; import { @@ -152,14 +151,64 @@ describe.skipIf(databaseUrl === undefined)( expect(report.action).toBe("migrated"); }); - // Closed-by-default: a hub with no WORKBENCH_SIGNUP override (the - // platform's own default, per `apps/hub/src/config.ts`) refuses a - // brand-new person outright, right at sign-up — the access-policy - // gate is wired into better-auth's own sign-up hook, one layer - // earlier than onboarding's own provisioning gate — proven against - // a short-lived hub of its own so the rest of this scenario's hub - // (which needs open signup to run at all) never muddies the - // assertion. + const hub: HubHandle = await hop("hub boot", async () => + startHub({ + databaseUrl: url, + port: freePort(), + sessionSecret: Buffer.from( + crypto.getRandomValues(new Uint8Array(32)), + ).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 — this + // scenario's own connect step is what finishes seeding it. + }), + ); + track(hub); + + const hubApi: ApiCall = createHubAPI(hub.baseUrl); + + const admin = await hop("alice genesis sign-up", async () => { + const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { + name: "Alice", + email: "alice@example.com", + password: "password123", + }); + expectStatus("alice sign-up", res, 200); + if (res.cookies.length === 0) { + throw new Error("alice sign-up returned no session cookie"); + } + const userId = stringField( + (res.data as { user: unknown }).user, + "id", + "alice sign-up user field", + ); + const probe = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + undefined, + res.cookies, + ); + expectStatus("alice genesis probe", probe, 200); + expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); + const minted = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + { name: "Workbench" }, + res.cookies, + ); + expectStatus("alice genesis provision", minted, 200); + expect((minted.data as { kind: string }).kind).toBe("provisioned"); + return { cookies: res.cookies, userId }; + }); + + // Occupied closed signup: a hub with WORKBENCH_SIGNUP=closed + // refuses a brand-new person once the hub is no longer empty — + // the empty-hub exception already admitted alice. Proven against + // a short-lived hub of its own so the rest of this scenario's + // open hub never muddies the assertion. await hop("closed-by-default signup is respected", async () => { const closedHub = await startHub({ databaseUrl: url, @@ -189,42 +238,12 @@ describe.skipIf(databaseUrl === undefined)( } }); - const hub: HubHandle = await hop("hub boot", async () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from( - crypto.getRandomValues(new Uint8Array(32)), - ).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 — this - // scenario's own connect step is what finishes seeding it. - }), - ); - track(hub); - - const hubApi: ApiCall = createHubAPI(hub.baseUrl); - const user = await hop("sign-up", () => signUp(hub.baseUrl, "Local Rip Tester"), ); - // 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( - "a membership probe joins the boot root as a member", + "a membership probe joins the genesis root as a member", async () => { const res = await api( hub.baseUrl, @@ -372,7 +391,7 @@ describe.skipIf(databaseUrl === undefined)( } // CL-7071: seedTenant/ensureSeeded no longer pack. The connect - // flow runs on the boot-ensured root itself, so publish + // flow runs on the genesis root itself, so publish // `corbits-tools` onto the root the way `workbench setup` does. // Then ensureSeeded deploys assistant without packing. await hop( diff --git a/scripts/e2e/play.ts b/scripts/e2e/play.ts index e7a26e5f3..a5280f09a 100644 --- a/scripts/e2e/play.ts +++ b/scripts/e2e/play.ts @@ -22,7 +22,6 @@ import { } from "../../packages/seeding/src/index.ts"; import { createHubAPI, - signIn, type ApiCall, } from "../../packages/hub-api-client/src/index.ts"; import { @@ -179,12 +178,48 @@ async function main(): Promise { const hubApi: ApiCall = createHubAPI(hub.baseUrl); + const admin = await hop("alice genesis sign-up", async () => { + const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { + name: "Alice", + email: "alice@example.com", + password: "password123", + }); + expectStatus("alice sign-up", res, 200); + if (res.cookies.length === 0) { + throw new Error("alice sign-up returned no session cookie"); + } + const userId = stringField( + (res.data as { user: unknown }).user, + "id", + "alice sign-up user field", + ); + const probe = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + undefined, + res.cookies, + ); + expectStatus("alice genesis probe", probe, 200); + expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); + const minted = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + { name: "Workbench" }, + res.cookies, + ); + expectStatus("alice genesis provision", minted, 200); + expect((minted.data as { kind: string }).kind).toBe("provisioned"); + return { cookies: res.cookies, userId }; + }); + const user = await hop("sign-up", () => signUp(hub.baseUrl, "Greeting Delivery Tester"), ); const provisioned = await hop( - "a membership probe joins the boot root as a member", + "a membership probe joins the genesis root as a member", async () => { const res = await api( hub.baseUrl, @@ -205,15 +240,7 @@ async function main(): Promise { ); // 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 }; - }); - + // below runs as alice, the genesis owner. const tenant = await hop( "the joined root resolves through findPersonalTenant", async () => { diff --git a/scripts/e2e/smoke-onboarding.test.ts b/scripts/e2e/smoke-onboarding.test.ts index f099c1358..1b9f594c3 100644 --- a/scripts/e2e/smoke-onboarding.test.ts +++ b/scripts/e2e/smoke-onboarding.test.ts @@ -1,14 +1,12 @@ // 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). 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: "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. +// (POST /api/onboarding/provision). The e2e hub boots empty, so under +// the CL-7578 genesis-or-join contract the first signup mints the root +// as owner — the same genesis path covered in-process by +// `apps/hub/test/signup-genesis.test.ts`. This asserts genesis over +// the wire: `kind: "needs-onboarding"` then `kind: "provisioned"` +// naming the minted root via `tenantId`/`tenantSlug`, and an +// idempotent re-provision. import { describe, expect, test } from "bun:test"; @@ -47,7 +45,7 @@ function stringField(data: unknown, field: string, what: string): string { describe.skipIf(databaseUrl === undefined)( "smoke: onboarding provision", () => { - test("a brand-new signup joins the boot root as a member, unseeded", async () => { + test("a brand-new signup mints the root as owner, unseeded", async () => { const url = databaseUrl; if (url === undefined) throw new Error("unreachable: suite is skipped"); @@ -86,16 +84,27 @@ describe.skipIf(databaseUrl === undefined)( }); const provisioned = await hop( - "a membership probe joins the boot root as a member", + "the first signup mints the root as owner", async () => { - const res = await api( + const probe = await api( baseUrl, "POST", "/api/onboarding/provision", undefined, cookies, ); - expectStatus("provision probe", res, 200); + expectStatus("provision probe", probe, 200); + expect((probe.data as { kind: string }).kind).toBe( + "needs-onboarding", + ); + const res = await api( + baseUrl, + "POST", + "/api/onboarding/provision", + { name: "Smoke Onboarding" }, + cookies, + ); + expectStatus("genesis provision", res, 200); const data = res.data as { kind: string; tenantId: string; @@ -103,7 +112,7 @@ describe.skipIf(databaseUrl === undefined)( seeded: boolean; seedSkipReason?: string; }; - expect(data.kind).toBe("existing-member"); + expect(data.kind).toBe("provisioned"); stringField(data, "tenantId", "provision result"); stringField(data, "tenantSlug", "provision result"); return data;