Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,10 @@ export type HubConfig = {
* see `ROUTINE_SCHEDULER_POLL_INTERVAL_MS` above. Unset runs the real
* production cadence (`routine-scheduler.ts`'s own default). */
readonly routineSchedulerPollIntervalMs?: number;
/** Test-only seam to skip the boot-time `ensureDefaultTenant` call so
* a suite can exercise the true empty-hub first-signup path (CL-7578).
* Never set by `readHubConfig` and never set for a real deployment. */
readonly skipEnsureDefaultTenant?: boolean;
/** Every sidecar-allocation backend registered for exclusive placement,
* one or more, each addressable by its provisioner id. Never empty: an
* install that configures nothing registers the `process` backend, so
Expand Down
64 changes: 44 additions & 20 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes";
import { generateId } from "@intx/hub-common";

import { ensureDefaultTenant } from "./default-tenant";
import { createHubSignupTenancy } from "./signup-tenancy";
import { runSystemSeed } from "./system-seed";
import {
createInMemoryMailboxEventBus,
Expand Down Expand Up @@ -669,6 +670,10 @@ export async function createHub(config: HubConfig) {
// Per-principal signing keys are sealed under their own operator key —
// see `principalKeyStoreFrom`.
const principalKeyStore = principalKeyStoreFrom(config, db, log);
// The genesis-or-join first-signup decision's tenancy reads/writes —
// also read by the sign-up gate (empty-hub exception) and the
// tenant-create guard below. See ./signup-tenancy.ts.
const signupTenancy = createHubSignupTenancy(db, principalKeyStore);

const auth = betterAuth({
baseURL: config.baseUrl,
Expand Down Expand Up @@ -741,14 +746,19 @@ export async function createHub(config: HubConfig) {
// membership too — `workbench setup` then adopts the root instead of
// colliding with it, and the root's policy row has an editor. Failure
// here fails the boot loudly — a hub without its root tenant cannot
// serve first logins.
const operatorTenantId = await ensureDefaultTenant(
db,
auth,
config.envCredentialPlantAdmin,
config.defaultTenantSlug,
principalKeyStore,
);
// serve first logins. The skip seam is test-only: a suite exercising
// the true empty-hub first-signup path sets it so the first signup
// itself mints the root (CL-7578); readHubConfig never sets it.
const operatorTenantId =
config.skipEnsureDefaultTenant === true
? undefined
: await ensureDefaultTenant(
db,
auth,
config.envCredentialPlantAdmin,
config.defaultTenantSlug,
principalKeyStore,
);
// Account-keyed sign-in rate limit (CL-6494) — see `sign-in-rate-limit.ts`
// for why this replaces better-auth's own IP-keyed sign-in enforcement
// entirely rather than composing with it.
Expand Down Expand Up @@ -1272,14 +1282,27 @@ export async function createHub(config: HubConfig) {
// sign-up/email path is product-controlled (docs/TENANCY.md).
if (c.req.method === "POST" && c.req.path.endsWith(SIGN_UP_EMAIL_PATH)) {
if (config.signupMode === "closed") {
return c.json(
{
error: "signup_closed",
message:
"Self-serve signup is disabled. Ask an owner for an invite.",
},
403,
);
// Empty-hub exception (CL-7578): with zero users and zero
// tenants, someone has to be first — the signup that opens a
// brand-new hub is allowed even when signup is closed, since
// the genesis path makes that caller the root tenant's owner.
// Everywhere else on the hub "empty" means zero tenants only;
// here a user row also counts, because it means the 0→1
// signup already happened.
const [users, tenants] = await Promise.all([
signupTenancy.countUsers(),
signupTenancy.countTenants(),
]);
if (users > 0 || tenants > 0) {
return c.json(
{
error: "signup_closed",
message:
"Self-serve signup is disabled. Ask an owner for an invite.",
},
403,
);
}
}
if (config.allowedEmailDomains.length > 0) {
let email = "";
Expand Down Expand Up @@ -3378,6 +3401,8 @@ export async function createHub(config: HubConfig) {

const onboardingDeps: Parameters<typeof createOnboardingRoutes>[0] = {
hubUrl: config.baseUrl,
defaultTenantSlug: config.defaultTenantSlug,
tenancy: signupTenancy,
pushWorkflow: createGitWorkflowPusher(),
log: (line) => log.info`${line}`,
logError: (line) => log.error`${line}`,
Expand All @@ -3396,9 +3421,6 @@ export async function createHub(config: HubConfig) {
// routed someone to onboarding to fix.
providerHealth: providerHealthStore,
};
onboardingDeps.operatorTenantId = operatorTenantId;
if (config.seedModel !== undefined)
onboardingDeps.seedModel = config.seedModel;
if (config.huggingfaceOAuthClientId !== undefined)
onboardingDeps.huggingfaceClientId = config.huggingfaceOAuthClientId;

Expand Down Expand Up @@ -3573,6 +3595,7 @@ export async function createHub(config: HubConfig) {
store: accessPolicyStore,
resolveCallerRoleNames: (tenantId, userId) =>
resolveCallerRoleNames(db, tenantId, userId),
countTenants: signupTenancy.countTenants,
envSignupMode: config.signupMode,
envAllowedDomains: config.allowedEmailDomains,
allowUnverifiedEmails: config.allowUnverifiedEmails,
Expand All @@ -3587,7 +3610,8 @@ export async function createHub(config: HubConfig) {
: undefined;
},
};
guardDeps.operatorTenantId = operatorTenantId;
if (operatorTenantId !== undefined)
guardDeps.operatorTenantId = operatorTenantId;
const guardedApp = guardedHubApp(app, guardDeps);
const inFlight = createInFlightRequestTracker();
const servingApp = withInFlightRequestTracking(guardedApp, inFlight);
Expand Down
95 changes: 95 additions & 0 deletions apps/hub/src/signup-tenancy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// The hub's `HubSignupTenancy` adapter (see
// packages/onboarding/src/genesis.ts): the native reads and writes the
// genesis-or-join first-signup decision needs — user/tenant counts,
// the root-tenant lookup, and the member-role join. Mirrors
// packages/chat/src/workbench-tenancy.ts's `addWorkbenchMember`
// (principalStore.create + principalRole "member") so a joined signup
// holds exactly the membership shape a workbench member holds. Reads
// native tables only; declares none of its own (see
// scripts/checks/no-product-tenancy.ts).
import { and, count, eq, isNull } from "drizzle-orm";
import { generateId } from "@intx/hub-common";
import {
createPrincipalStore,
type DB,
type PrincipalKeyStore,
} from "@intx/db";
import {
principal,
principalRole,
role,
tenant,
user as userTable,
} from "@intx/db/schema";
import type { HubSignupTenancy } from "@workbench/onboarding";

export function createHubSignupTenancy(
db: DB["db"],
principalKeyStore: PrincipalKeyStore,
): HubSignupTenancy {
const principalStore = createPrincipalStore(db, principalKeyStore);
return {
countUsers: async () => {
const [row] = await db.select({ n: count() }).from(userTable);
return row?.n ?? 0;
},
countTenants: async () => {
const [row] = await db.select({ n: count() }).from(tenant);
return row?.n ?? 0;
},
findRootTenant: async () => {
const [row] = await db
.select({ id: tenant.id, slug: tenant.slug })
.from(tenant)
.where(isNull(tenant.parentId))
.limit(1);
return row ?? null;
},
addActiveMember: async ({ tenantId, userId, roleName }) =>
db.transaction(async (tx) => {
const [existing] = await tx
.select({ id: principal.id })
.from(principal)
.where(
and(
eq(principal.tenantId, tenantId),
eq(principal.kind, "user"),
eq(principal.refId, userId),
),
)
.limit(1);
if (existing !== undefined) return { principalId: existing.id };

const [memberRole] = await tx
.select({ id: role.id })
.from(role)
.where(and(eq(role.tenantId, tenantId), eq(role.name, roleName)))
.limit(1);
if (memberRole === undefined) {
throw new Error(
`signup join: tenant "${tenantId}" has no "${roleName}" system role`,
);
}

const now = new Date();
const created = await principalStore.create(
{
id: generateId("principal"),
tenantId,
kind: "user",
refId: userId,
status: "active",
createdAt: now,
updatedAt: now,
},
tx,
);
await tx.insert(principalRole).values({
principalId: created.id,
roleId: memberRole.id,
createdAt: now,
});
return { principalId: created.id };
}),
};
}
13 changes: 13 additions & 0 deletions apps/hub/src/tenant-create-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export type TenantCreateGuardDeps = {
tenantId: string,
userId: string,
) => Promise<readonly string[] | undefined>;
/** Total native tenant count — the hub's signup-tenancy adapter's own
* read, injected so the decision stays DB-free in tests. */
countTenants: () => Promise<number>;
operatorTenantId?: string;
envSignupMode: "open" | "closed";
envAllowedDomains: readonly string[];
Expand Down Expand Up @@ -115,6 +118,16 @@ export async function decideTenantCreate(
request.parentId === undefined ||
request.parentId === deps.operatorTenantId
) {
// Genesis on an empty hub (CL-7578): with zero tenants there is no
// policy, no operator tenant, and nobody to invite anyone — the
// first signup's unparented create is the one path that bypasses
// the signup gate, because the sign-up route's own empty-hub
// exception already admitted this caller. Empty means zero tenants
// only (the one emptiness rule shared with genesis.ts; user rows
// alone never make a hub occupied).
if (request.parentId === undefined && (await deps.countTenants()) === 0) {
return { allowed: true };
}
type MutableSignupGateArgs = {
-readonly [K in keyof Parameters<typeof checkSignupGate>[0]]: Parameters<
typeof checkSignupGate
Expand Down
Loading
Loading