diff --git a/.env.example b/.env.example index fa6df90b3..37c9185d4 100644 --- a/.env.example +++ b/.env.example @@ -43,34 +43,32 @@ HUB_ALLOW_GIT_INSIDE_WORK_TREE=1 # with an index.html works — ../hub/public is a minimal placeholder page. HUB_STATIC_DIR=../web/dist -# The administrator account: `bun run dev` seeds it once the hub answers -# so a fresh checkout can sign in immediately, and `workbench setup` / -# `workbench seed` authenticate as it. Sign in with these credentials -# right away. In `bun run dev`, leave either value empty to skip seeding -# (hosted deployments do not run `bun run dev`). The hub itself also -# authenticates as this account (and resolves ORG_SLUG below) to find -# the operator bench for the env-key auto-plant — see ANTHROPIC_API_KEY -# further down. +# The administrator account: the hub seeds it at boot and makes it the +# owner of the root tenant (WORKBENCH_DEFAULT_TENANT below), so a fresh +# checkout can sign in immediately and `workbench setup` / `workbench +# seed` authenticate as it. Sign in with these credentials right away. +# Unset values fall back to the defaults shown. The hub itself also +# authenticates as this account (and resolves the same root slug) to +# find the operator bench for the env-key auto-plant — see +# ANTHROPIC_API_KEY further down. # Administrator identity. Unset values fall back to the defaults shown — # fine for local development, set both for real deployments. # HUB_ADMIN_EMAIL=alice@example.com # HUB_ADMIN_PASSWORD=password123 -# The operator bench's slug — `workbench setup` creates it, `workbench -# seed` and the hub's own env-key auto-plant resolve it. Unset falls -# back to "workbench", the same default those commands use. -# ORG_SLUG=workbench - # Everything below is optional. Leave a variable unset to leave the # feature it configures off; the hub never treats a partially-set group # as configured — it fails loudly at boot instead. -# The tenant every self-served personal bench is parented under. -# `workbench setup` writes this to the org tenant it created. Leave it -# blank only for isolated tests that need an unparented personal bench -# — that is not the default self-serve story. Restart the hub after -# setup so it reads the new value. -# OPERATOR_TENANT_ID= +# Slug of the root tenant the hub ensures at boot. Every self-served +# personal bench parents under it, and `workbench setup` / `workbench +# seed` / the env-key auto-plant resolve the same slug. Unset falls back +# to "workbench". ORG_SLUG is an alias when this is unset — set only one. +# Upgrading a deploy whose existing root was not "workbench": set this +# to that slug (do not leave OPERATOR_TENANT_ID; the hub refuses to boot +# while that stale key is set). +# WORKBENCH_DEFAULT_TENANT=workbench +# ORG_SLUG=workbench # Per-IP rate limit on email sign-up. Defaults to 5 sign-ups per 60 # seconds when unset. diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index dff805f42..db37b48f5 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -80,8 +80,10 @@ const HubEnv = type({ HUB_STATIC_DIR: type("string > 0").describe( "a directory of built user-interface files the hub serves, e.g. apps/hub/public", ), - "OPERATOR_TENANT_ID?": type("string > 0").describe( - "the tenant id every self-served personal bench is parented under; workbench setup writes this for the org tenant it creates. Leave unset only for isolated tests that need an unparented personal bench", + "WORKBENCH_DEFAULT_TENANT?": type( + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, + ).describe( + 'slug of the root tenant the hub ensures at boot; every self-served personal bench parents under it, and setup/seed/plant resolve the same slug — ORG_SLUG is an alias when this is unset; default "workbench"', ), "SIGNUP_RATE_LIMIT_WINDOW_SECONDS?": type(/^[1-9]\d*$/).describe( "the per-IP sign-up rate-limit window, in seconds, e.g. 60", @@ -147,7 +149,7 @@ const HubEnv = type({ "the administrator password the env-key auto-plant signs in with; unset falls back to password123, the same default `workbench setup`/`workbench seed` use", ), "ORG_SLUG?": type(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/).describe( - 'the operator bench slug the env-key auto-plant resolves; same variable `workbench setup`/`workbench seed` read — unset falls back to "workbench"', + 'alias for WORKBENCH_DEFAULT_TENANT when that is unset — same root/operator slug the hub, setup, seed, and the env-key auto-plant resolve; default "workbench"', ), "GOOGLE_CLIENT_ID?": type("string > 0").describe( "Google OAuth client id; set together with GOOGLE_CLIENT_SECRET to enable Google sign-in", @@ -327,7 +329,7 @@ export type HubConfig = { readonly sessionSecret: string; readonly hubDataDir: string; readonly hubStaticDir: string; - readonly operatorTenantId?: string; + readonly defaultTenantSlug: string; readonly signupRateLimit: { readonly windowSeconds: number; readonly max: number; @@ -606,6 +608,14 @@ function seedModelFrom(parsed: ParsedHubEnv): ModelSource | undefined { export function readHubConfig( env: Record, ): 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. " + + "Set WORKBENCH_DEFAULT_TENANT to your existing root tenant's slug " + + '(or remove OPERATOR_TENANT_ID to keep the default slug "workbench"), then restart.', + ); + } + const parsed = HubEnv(env); if (parsed instanceof type.errors) { throw new Error( @@ -628,12 +638,21 @@ export function readHubConfig( .map((d) => d.trim()) .filter((d) => d.length > 0); + // One deployment fact shared by boot parenting, setup/seed, and the + // env-key auto-plant. WORKBENCH_DEFAULT_TENANT wins; ORG_SLUG is the + // alias when that is unset. + const defaultTenantSlug = + parsed.WORKBENCH_DEFAULT_TENANT ?? + parsed.ORG_SLUG ?? + DEFAULT_PLANT_ORG_SLUG; + const hubConfig: { -readonly [K in keyof HubConfig]: HubConfig[K] } = { databaseUrl: parsed.DATABASE_URL, baseUrl: parsed.BASE_URL, sessionSecret: parsed.SESSION_SECRET, hubDataDir: parsed.HUB_DATA_DIR, hubStaticDir: parsed.HUB_STATIC_DIR, + defaultTenantSlug, socialProviders, signupMode: parsed.WORKBENCH_SIGNUP ?? "closed", allowedEmailDomains, @@ -661,7 +680,7 @@ export function readHubConfig( envCredentialPlantAdmin: { email: parsed.HUB_ADMIN_EMAIL ?? DEFAULT_PLANT_ADMIN_EMAIL, password: parsed.HUB_ADMIN_PASSWORD ?? DEFAULT_PLANT_ADMIN_PASSWORD, - orgSlug: parsed.ORG_SLUG ?? DEFAULT_PLANT_ORG_SLUG, + orgSlug: defaultTenantSlug, }, chatIdleReapMs: parsePositiveMsEnv( parsed.WORKBENCH_CHAT_IDLE_REAP_MS, @@ -669,8 +688,6 @@ export function readHubConfig( DEFAULT_CHAT_IDLE_REAP_MS, ), }; - if (parsed.OPERATOR_TENANT_ID !== undefined) - hubConfig.operatorTenantId = parsed.OPERATOR_TENANT_ID; if (parsed.HUB_ALLOW_GIT_INSIDE_WORK_TREE !== undefined) hubConfig.allowGitInsideWorkTree = true; if (parsed.PORT !== undefined) hubConfig.listenPort = Number(parsed.PORT); diff --git a/apps/hub/src/default-tenant.test.ts b/apps/hub/src/default-tenant.test.ts new file mode 100644 index 000000000..44da46e4d --- /dev/null +++ b/apps/hub/src/default-tenant.test.ts @@ -0,0 +1,354 @@ +// 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/harness"; +import { setupDatabase } from "../../../scripts/db-setup"; +import { dbGate } from "../../../scripts/e2e/db-gate"; +import { createDB } from "@intx/db"; +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, + }); + + 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"); + 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"); + + 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"); + const second = await ensureDefaultTenant(db.db, auth, ADMIN, "acme"); + + 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"); + 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"); + 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"); + 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"); + 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"); + + 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"); + 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 new file mode 100644 index 000000000..014c54734 --- /dev/null +++ b/apps/hub/src/default-tenant.ts @@ -0,0 +1,376 @@ +// 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 type { DB } 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, +): 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 db + .insert(principal) + .values({ + id: generateId("principal"), + tenantId, + kind: "user", + refId: adminUserId, + status: "active", + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing(); + + 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, +): 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); + + return tenantId; +} diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index b3292a08b..5ce5bac9b 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -153,6 +153,8 @@ import { createSidecarPlacementRoutes, } from "@corbits/sidecar-placement"; import { generateId } from "@intx/hub-common"; + +import { ensureDefaultTenant } from "./default-tenant"; import { createInMemoryMailboxEventBus, createMailboxDb, @@ -629,6 +631,19 @@ 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. + const operatorTenantId = await ensureDefaultTenant( + db, + auth, + config.envCredentialPlantAdmin, + config.defaultTenantSlug, + ); // 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. @@ -3309,8 +3324,7 @@ export async function createHub(config: HubConfig) { // routed someone to onboarding to fix. providerHealth: providerHealthStore, }; - if (config.operatorTenantId !== undefined) - onboardingDeps.operatorTenantId = config.operatorTenantId; + onboardingDeps.operatorTenantId = operatorTenantId; if (config.seedModel !== undefined) onboardingDeps.seedModel = config.seedModel; if (config.huggingfaceOAuthClientId !== undefined) @@ -3501,9 +3515,7 @@ export async function createHub(config: HubConfig) { : undefined; }, }; - if (config.operatorTenantId !== undefined) { - guardDeps.operatorTenantId = config.operatorTenantId; - } + guardDeps.operatorTenantId = operatorTenantId; const guardedApp = guardedHubApp(app, guardDeps); const inFlight = createInFlightRequestTracker(); const servingApp = withInFlightRequestTracking(guardedApp, inFlight); diff --git a/apps/hub/test/chat-mount.test.ts b/apps/hub/test/chat-mount.test.ts index 56943ebd5..fb9379944 100644 --- a/apps/hub/test/chat-mount.test.ts +++ b/apps/hub/test/chat-mount.test.ts @@ -29,6 +29,7 @@ const config: HubConfig = { 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: {}, diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index 2813c76a7..acfb437f9 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -31,6 +31,7 @@ const config: HubConfig = { 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: {}, diff --git a/apps/hub/test/config.test.ts b/apps/hub/test/config.test.ts index da9655a19..da1ee3640 100644 --- a/apps/hub/test/config.test.ts +++ b/apps/hub/test/config.test.ts @@ -28,6 +28,7 @@ describe("readHubConfig", () => { sessionSecret: validEnv.SESSION_SECRET, hubDataDir: validEnv.HUB_DATA_DIR, hubStaticDir: validEnv.HUB_STATIC_DIR, + defaultTenantSlug: "workbench", socialProviders: {}, signupMode: "closed", allowedEmailDomains: [], @@ -167,12 +168,49 @@ describe("readHubConfig", () => { }); }); - test("OPERATOR_TENANT_ID is optional and absent by default", () => { - expect(readHubConfig(validEnv).operatorTenantId).toBeUndefined(); + test("WORKBENCH_DEFAULT_TENANT defaults to workbench and accepts an explicit slug", () => { + expect(readHubConfig(validEnv).defaultTenantSlug).toBe("workbench"); expect( - readHubConfig({ ...validEnv, OPERATOR_TENANT_ID: "ten_operator" }) - .operatorTenantId, - ).toBe("ten_operator"); + readHubConfig({ ...validEnv, WORKBENCH_DEFAULT_TENANT: "acme" }) + .defaultTenantSlug, + ).toBe("acme"); + }); + + test("ORG_SLUG aliases WORKBENCH_DEFAULT_TENANT when the latter is unset", () => { + const config = readHubConfig({ ...validEnv, ORG_SLUG: "acme" }); + expect(config.defaultTenantSlug).toBe("acme"); + expect(config.envCredentialPlantAdmin.orgSlug).toBe("acme"); + }); + + test("WORKBENCH_DEFAULT_TENANT wins over ORG_SLUG when both are set", () => { + const config = readHubConfig({ + ...validEnv, + WORKBENCH_DEFAULT_TENANT: "root", + ORG_SLUG: "acme", + }); + expect(config.defaultTenantSlug).toBe("root"); + expect(config.envCredentialPlantAdmin.orgSlug).toBe("root"); + }); + + test("OPERATOR_TENANT_ID fails loudly with an actionable message", () => { + const message = readExpectingError({ + ...validEnv, + OPERATOR_TENANT_ID: "tnt_stale", + }); + expect(message).toContain("OPERATOR_TENANT_ID"); + expect(message).toContain("WORKBENCH_DEFAULT_TENANT"); + }); + + test("WORKBENCH_DEFAULT_TENANT rejects a non-slug value", () => { + expect( + readExpectingError({ ...validEnv, WORKBENCH_DEFAULT_TENANT: "" }), + ).toContain("WORKBENCH_DEFAULT_TENANT"); + expect( + readExpectingError({ + ...validEnv, + WORKBENCH_DEFAULT_TENANT: "Not A Slug", + }), + ).toContain("WORKBENCH_DEFAULT_TENANT"); }); test("the signup rate limit is configurable and defaults sanely", () => { diff --git a/apps/hub/test/credential-cipher.test.ts b/apps/hub/test/credential-cipher.test.ts index 86e3aebb0..b5f75c8d1 100644 --- a/apps/hub/test/credential-cipher.test.ts +++ b/apps/hub/test/credential-cipher.test.ts @@ -17,6 +17,7 @@ const baseConfig: HubConfig = { sessionSecret: "insecure-test-only-session-secret-0000", hubDataDir: ".data/hub", hubStaticDir: "apps/hub/public", + defaultTenantSlug: "workbench", signupRateLimit: { windowSeconds: 60, max: 5 }, signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, diff --git a/apps/hub/test/eval-runs-mount.test.ts b/apps/hub/test/eval-runs-mount.test.ts index 85d33fad3..c65618406 100644 --- a/apps/hub/test/eval-runs-mount.test.ts +++ b/apps/hub/test/eval-runs-mount.test.ts @@ -30,6 +30,7 @@ const config: HubConfig = { 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: {}, diff --git a/apps/hub/test/presence-mount.test.ts b/apps/hub/test/presence-mount.test.ts index 68607cbee..7f745f859 100644 --- a/apps/hub/test/presence-mount.test.ts +++ b/apps/hub/test/presence-mount.test.ts @@ -29,6 +29,7 @@ const config: HubConfig = { 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: {}, diff --git a/apps/hub/test/routine-mount.test.ts b/apps/hub/test/routine-mount.test.ts index b6c4d3969..c526ec360 100644 --- a/apps/hub/test/routine-mount.test.ts +++ b/apps/hub/test/routine-mount.test.ts @@ -30,6 +30,7 @@ const config: HubConfig = { 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: {}, diff --git a/apps/hub/test/slack-tag-mount.test.ts b/apps/hub/test/slack-tag-mount.test.ts index c42a82690..2b5bdd984 100644 --- a/apps/hub/test/slack-tag-mount.test.ts +++ b/apps/hub/test/slack-tag-mount.test.ts @@ -33,6 +33,7 @@ const config: HubConfig = { 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: {}, diff --git a/docs/TENANCY.md b/docs/TENANCY.md index f77a37a04..0e50dfcf1 100644 --- a/docs/TENANCY.md +++ b/docs/TENANCY.md @@ -11,19 +11,42 @@ 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 `OPERATOR_TENANT_ID`; `workbench setup` writes that id for the org tenant it creates | -| 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 | +| 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 | Inheritance is **live**. Creating a sub-workbench must **not** copy catalog rows, credentials, or providers from the parent — resolution walks the chain on every read. +### Root tenant slug (one deployment fact) + +The hub ensures a root tenant at boot by slug. That same slug is the +operator bench `workbench setup` / `workbench seed` and the env-key +auto-plant resolve: + +1. `WORKBENCH_DEFAULT_TENANT` if set +2. else `ORG_SLUG` (alias) +3. else `workbench` + +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 +`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 +back to `WORKBENCH_SIGNUP` until Settings → People → "Who can join" +writes one. This cutover does not migrate policy rows from a previous +operator tenant. + ## Workbench-side contracts (this repo) ### Signup mode @@ -38,9 +61,9 @@ is never patched into a vendor route. Two layers, in order: when the variable is unset, so a zero-edit `.env` can still seed the admin account; an explicit value in `.env` always wins; production deploys that do not use the dev launcher keep the closed default. -2. **Policy row (operator tenant, once set)**: once `OPERATOR_TENANT_ID` - carries an explicit `access_policy.policy` row (editable from Settings - → People → "Who can join"), that row decides outright and the env +2. **Policy row (root tenant)**: the boot-ensured root tenant can carry an + explicit `access_policy.policy` row (editable from Settings + → People → "Who can join"); that row decides outright and the env flag is no longer consulted — `selfSignup` is `"off"`, `"allowed- domains"` (with an `allowedDomains` list), or `"open"`. An absent row is closed defaults, identical in effect to `selfSignup: "off"`. diff --git a/docs/local-rip.md b/docs/local-rip.md index dccf78fd9..0f2541305 100644 --- a/docs/local-rip.md +++ b/docs/local-rip.md @@ -104,12 +104,13 @@ tarball (see `apps/hub/src/index.ts`'s `CORBITS_TOOLS_REGISTRY` comment). `@corbits/tool-registry-publish` (bundles `@corbits/memory-tools` into a self-contained tarball and pushes it through the hub's native asset REST routes). Descendants inherit it; `seedTenant` does not pack. Isolated -tests leave `OPERATOR_TENANT_ID` blank so the walkthrough's personal +tests run with no explicit tenant config so the walkthrough's personal bench is itself the root — then the same publish happens once onto that bench, and **echo**, **workbench-digest**, and **assistant** all come up live. `scripts/e2e/local-rip.test.ts` asserts exactly that. The default -self-serve story is the other way: setup writes `OPERATOR_TENANT_ID` for -the org tenant, and first-login personal benches parent under it. +self-serve story is the other way: the hub ensures a root tenant at boot +(`WORKBENCH_DEFAULT_TENANT`, default `workbench`), and first-login +personal benches parent under it. ## 5. Check the Connections surface diff --git a/packages/cli/README.md b/packages/cli/README.md index 259871acb..9647accda 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -25,11 +25,11 @@ hosted provisioning, and every one of them is safe to re-run. a single arktype schema, and every missing/malformed variable is reported at once with the exact fix. - `setup.ts` — `workbench setup`: initializes the database, provisions - the bench through the hub's native tenant-creation route, writes - `OPERATOR_TENANT_ID` for that org tenant so first-login benches parent - under it, publishes the platform `corbits-tools` registry onto that - root tenant (descendants inherit it), and reports the role defaults - the platform created. + the org bench through the hub's native tenant-creation route (or + adopts the boot-ensured root when the operator is already a member), + publishes the platform `corbits-tools` registry onto that root tenant + (descendants inherit it), and reports the role defaults the platform + created. - `seed.ts` — `workbench seed`: authenticates as the administrator, resolves the configured bench by slug, and deploys the default workflow set. It does not pack tarballs. diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index f1a2c95b0..6f1f8b1b7 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -36,7 +36,12 @@ const SharedEnv = { "the administrator password, at least 8 characters", ), "ORG_SLUG?": type(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/).describe( - "a lowercase-kebab bench slug, e.g. workbench", + "alias for WORKBENCH_DEFAULT_TENANT when that is unset — a lowercase-kebab root/operator bench slug, e.g. workbench", + ), + "WORKBENCH_DEFAULT_TENANT?": type( + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, + ).describe( + "slug of the root tenant the hub ensures at boot; setup and seed resolve the same slug — ORG_SLUG is an alias when this is unset; default workbench", ), } as const; @@ -147,7 +152,7 @@ export function readSetupConfig( adminPassword: admin.adminPassword, adminDefaulted: admin.adminDefaulted, orgName: parsed.ORG_NAME ?? "Workbench", - orgSlug: parsed.ORG_SLUG ?? "workbench", + orgSlug: parsed.WORKBENCH_DEFAULT_TENANT ?? parsed.ORG_SLUG ?? "workbench", }; } @@ -172,7 +177,7 @@ export function readSeedConfig( adminEmail: admin.adminEmail, adminPassword: admin.adminPassword, adminDefaulted: admin.adminDefaulted, - orgSlug: parsed.ORG_SLUG ?? "workbench", + orgSlug: parsed.WORKBENCH_DEFAULT_TENANT ?? parsed.ORG_SLUG ?? "workbench", modelSource: { provider: DEFAULT_MODEL_PROVIDER, model: DEFAULT_MODEL, diff --git a/packages/cli/src/env-file.test.ts b/packages/cli/src/env-file.test.ts deleted file mode 100644 index 483bba13a..000000000 --- a/packages/cli/src/env-file.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, test } from "bun:test"; -import { persistEnvVar, upsertEnvAssignment } from "./env-file"; - -describe("upsertEnvAssignment", () => { - test("appends when the key is absent", () => { - expect( - upsertEnvAssignment( - "BASE_URL=http://localhost:3000\n", - "OPERATOR_TENANT_ID", - "ten_1", - ), - ).toBe("BASE_URL=http://localhost:3000\nOPERATOR_TENANT_ID=ten_1\n"); - }); - - test("replaces an existing assignment", () => { - expect( - upsertEnvAssignment( - "OPERATOR_TENANT_ID=ten_old\nBASE_URL=x\n", - "OPERATOR_TENANT_ID", - "ten_1", - ), - ).toBe("OPERATOR_TENANT_ID=ten_1\nBASE_URL=x\n"); - }); - - test("uncomments the example assignment rather than appending a second copy", () => { - expect( - upsertEnvAssignment( - "# OPERATOR_TENANT_ID=\nBASE_URL=x\n", - "OPERATOR_TENANT_ID", - "ten_1", - ), - ).toBe("OPERATOR_TENANT_ID=ten_1\nBASE_URL=x\n"); - }); - - test("prefers an uncommented assignment when a commented example is also present", () => { - expect( - upsertEnvAssignment( - "# OPERATOR_TENANT_ID=\nOPERATOR_TENANT_ID=ten_old\n", - "OPERATOR_TENANT_ID", - "ten_1", - ), - ).toBe("# OPERATOR_TENANT_ID=\nOPERATOR_TENANT_ID=ten_1\n"); - }); -}); - -describe("persistEnvVar", () => { - test("creates the file when it is missing", async () => { - const dir = await mkdtemp(join(tmpdir(), "workbench-env-")); - const envPath = join(dir, ".env"); - await persistEnvVar(envPath, "OPERATOR_TENANT_ID", "ten_1"); - expect(await readFile(envPath, "utf8")).toBe("OPERATOR_TENANT_ID=ten_1\n"); - }); - - test("updates an existing .env in place", async () => { - const dir = await mkdtemp(join(tmpdir(), "workbench-env-")); - const envPath = join(dir, ".env"); - await writeFile(envPath, "BASE_URL=http://localhost:3000\n", "utf8"); - await persistEnvVar(envPath, "OPERATOR_TENANT_ID", "ten_1"); - expect(await readFile(envPath, "utf8")).toBe( - "BASE_URL=http://localhost:3000\nOPERATOR_TENANT_ID=ten_1\n", - ); - }); -}); diff --git a/packages/cli/src/env-file.ts b/packages/cli/src/env-file.ts deleted file mode 100644 index 6836e0abf..000000000 --- a/packages/cli/src/env-file.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Upsert a KEY=value assignment in a dotenv file. `workbench setup` -// uses this to persist OPERATOR_TENANT_ID into the repository `.env` -// after it creates the org tenant, so first-login provision can parent -// personal benches under it. - -import { readFile, writeFile } from "node:fs/promises"; - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -/** Replace or append `key=value` in dotenv `contents`. */ -export function upsertEnvAssignment( - contents: string, - key: string, - value: string, -): string { - const line = `${key}=${value}`; - const escaped = escapeRegExp(key); - const uncommented = new RegExp(`^[ \\t]*${escaped}=.*$`, "m"); - if (uncommented.test(contents)) { - return contents.replace(uncommented, line); - } - const commented = new RegExp(`^#[ \\t]*${escaped}=.*$`, "m"); - if (commented.test(contents)) { - return contents.replace(commented, line); - } - if (contents === "") return `${line}\n`; - const prefix = contents.endsWith("\n") ? contents : `${contents}\n`; - return `${prefix}${line}\n`; -} - -/** Read `envPath` (or start empty if missing), upsert, and write back. */ -export async function persistEnvVar( - envPath: string, - key: string, - value: string, -): Promise { - let contents = ""; - try { - contents = await readFile(envPath, "utf8"); - } catch (cause) { - if ( - !(cause instanceof Error) || - !("code" in cause) || - cause.code !== "ENOENT" - ) { - throw cause; - } - } - const next = upsertEnvAssignment(contents, key, value); - if (next === contents) return; - await writeFile(envPath, next, "utf8"); -} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 182c9666a..e5aff640a 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -10,7 +10,6 @@ import { } from "@workbench/hub-client"; import { readSeedConfig, readSetupConfig } from "./config"; import { createDbSetupRunner, createResetRunner } from "./db-setup"; -import { persistEnvVar } from "./env-file"; import { runReset } from "./reset"; import { runSeed } from "./seed"; import { runSetup } from "./setup"; @@ -61,8 +60,6 @@ async function main(argv: string[]): Promise { config, api: createHubAPI(config.hubUrl), runDbSetup: createDbSetupRunner(REPO_ROOT), - persistEnv: ({ key, value }) => - persistEnvVar(resolve(REPO_ROOT, ".env"), key, value), log: out, }); return; diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 8e366c11c..0e034b57d 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -1,8 +1,8 @@ // `workbench setup`: initialize the database, provision the -// bench through the hub's native tenant-creation route, persist -// OPERATOR_TENANT_ID so first-login benches parent under it, publish +// bench through the hub's native tenant-creation route, publish // the platform `corbits-tools` registry onto that tenant (the -// root; descendants inherit it), report the role defaults the +// hub boot's default tenant parents personal benches under it; +// descendants inherit the registry), report the role defaults the // platform created, and state exactly what the operator must // still supply. Safe to re-run; every skipped step says so. @@ -34,12 +34,6 @@ export type SetupDeps = { * bundle a tarball. */ publishToolRegistry?: ToolRegistryPublisher; - /** - * Writes `OPERATOR_TENANT_ID` into the operator `.env` so first-login - * personal benches parent under the org tenant this setup created. - * Isolated tests omit this; the CLI always supplies it. - */ - persistEnv?: (args: { key: string; value: string }) => Promise; }; async function ensureTenant( @@ -124,22 +118,6 @@ export async function runSetup(deps: SetupDeps): Promise { log, ); - if (deps.persistEnv !== undefined) { - try { - await deps.persistEnv({ key: "OPERATOR_TENANT_ID", value: tenantId }); - } catch (cause) { - const message = cause instanceof Error ? cause.message : String(cause); - throw new CliError( - `could not persist OPERATOR_TENANT_ID=${tenantId}: ${message}`, - "check that .env at the repository root is writable, then re-run: workbench setup", - { cause }, - ); - } - log( - `OPERATOR_TENANT_ID=${tenantId} written so first-login benches parent under this org`, - ); - } - const roles = await api( "GET", `/api/tenants/${tenantId}/roles?limit=100`, diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index 6da871d8b..d39e73f14 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -63,6 +63,22 @@ describe("readSetupConfig", () => { readSetupConfig({ ...VALID_SHARED, ORG_SLUG: "Not A Slug" }), ).toThrow(CliError); }); + + test("ORG_SLUG sets the bench slug when WORKBENCH_DEFAULT_TENANT is unset", () => { + expect(readSetupConfig({ ...VALID_SHARED, ORG_SLUG: "acme" }).orgSlug).toBe( + "acme", + ); + }); + + test("WORKBENCH_DEFAULT_TENANT wins over ORG_SLUG for the bench slug", () => { + expect( + readSetupConfig({ + ...VALID_SHARED, + WORKBENCH_DEFAULT_TENANT: "root", + ORG_SLUG: "acme", + }).orgSlug, + ).toBe("root"); + }); }); describe("readSeedConfig", () => { diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index adbf49d88..d1ce6aad1 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -321,109 +321,4 @@ describe("runSetup", () => { expect((caught as CliError).fix).toContain("workbench setup"); expect((caught as CliError).fix).not.toContain("workbench seed"); }); - - test("writes OPERATOR_TENANT_ID for the org tenant it created", async () => { - const { log } = collector(); - const persisted: { key: string; value: string }[] = []; - const api = fakeAPI((method, path) => { - if (method === "POST" && path === "/api/auth/sign-in/email") - return signInMissing(); - if (method === "POST" && path === "/api/auth/sign-up/email") - return signUpResponse(); - if (method === "POST" && path === "/api/tenants") - return { status: 201, data: tenantRow() }; - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/roles`) - ) - return rolesResponse(["owner", "admin", "member"]); - return undefined; - }); - - await runSetup({ - config: CONFIG, - api, - runDbSetup: okDbSetup, - publishToolRegistry: noopPublishToolRegistry, - persistEnv: async (args) => { - persisted.push(args); - }, - log, - }); - - expect(persisted).toEqual([ - { key: "OPERATOR_TENANT_ID", value: TENANT_ID }, - ]); - }); - - test("a re-run still writes OPERATOR_TENANT_ID for the existing org tenant", async () => { - const { log } = collector(); - const persisted: { key: string; value: string }[] = []; - const api = fakeAPI((method, path) => { - if (method === "POST" && path === "/api/auth/sign-in/email") - return signUpResponse(); - if (method === "POST" && path === "/api/tenants") - return { status: 409, data: { error: "slug taken" } }; - if (method === "GET" && path === "/api/me/principals") - return principalsResponse(); - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/roles`) - ) - return rolesResponse(["owner", "admin", "member"]); - return undefined; - }); - - await runSetup({ - config: CONFIG, - api, - runDbSetup: okDbSetup, - publishToolRegistry: async () => [], - persistEnv: async (args) => { - persisted.push(args); - }, - log, - }); - - expect(persisted).toEqual([ - { key: "OPERATOR_TENANT_ID", value: TENANT_ID }, - ]); - }); - - test("a persistEnv failure is a setup CliError", async () => { - const { log } = collector(); - const api = fakeAPI((method, path) => { - if (method === "POST" && path === "/api/auth/sign-in/email") - return signInMissing(); - if (method === "POST" && path === "/api/auth/sign-up/email") - return signUpResponse(); - if (method === "POST" && path === "/api/tenants") - return { status: 201, data: tenantRow() }; - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/roles`) - ) - return rolesResponse(["owner", "admin", "member"]); - return undefined; - }); - - let caught: unknown; - try { - await runSetup({ - config: CONFIG, - api, - runDbSetup: okDbSetup, - publishToolRegistry: noopPublishToolRegistry, - persistEnv: async () => { - throw new Error(".env is not writable"); - }, - log, - }); - } catch (error) { - caught = error; - } - expect(caught).toBeInstanceOf(CliError); - expect((caught as CliError).message).toContain("OPERATOR_TENANT_ID"); - expect((caught as CliError).fix).toContain(".env"); - }); }); diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index 651f483af..03cae6b8c 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -1121,7 +1121,7 @@ describe("provisionPersonalTenantIfNeeded", () => { }); test("isFullySeeded lists tenant-local assets only (inherited=false)", async () => { - // OPERATOR_TENANT_ID trees can surface the parent's workflow assets when + // 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. diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index 3bc0dd80c..f3c623f39 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -18,10 +18,10 @@ // `@corbits/memory-tools`, and that pin only resolved once an operator // 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). This -// suite has no operator-root split (`OPERATOR_TENANT_ID` is unset), so -// the provisioned personal bench *is* the root: an explicit -// `publishCorbitsToolsRegistry` hop onto that tenant stands in for +// 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 // setup, then `ensureSeeded` deploys without packing. // // Stubbing note: onboarding's own `POST /api/onboarding/complete` route @@ -372,11 +372,11 @@ describe.skipIf(databaseUrl === undefined)( } } - // CL-7071: seedTenant/ensureSeeded no longer pack. This suite's - // provisioned personal bench is the root (`OPERATOR_TENANT_ID` - // unset), so publish `corbits-tools` onto it the same way - // `workbench setup` does onto the operator-created bench. Then - // ensureSeeded deploys assistant without packing. + // 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. await hop( "publish corbits-tools onto the provisioned root bench (setup's job, not seed's)", async () => {