coldstart

Multi-Tenant & RBAC

Organizations, memberships, invitations, and role-based access control

Coldstart generates a complete multi-tenant system with organizations, memberships, invitations, and optional RBAC. Enable it during coldstart init or add it later with coldstart add tenant.

What's generated

tenant-schema.ts
organization.ts
rbac.ts

Three database tables and two middleware:

ComponentFilePurpose
organization tablepackages/db/src/tenant-schema.tsOrg name, slug, logo, metadata
member tablepackages/db/src/tenant-schema.tsUser-org relationship with role
invitation tablepackages/db/src/tenant-schema.tsEmail invitations with token + expiry
Organization middlewareapps/api/src/middleware/organization.tsExtract org context, verify membership
RBAC middlewareapps/api/src/middleware/rbac.tsRole hierarchy enforcement

Database schema

Organization

packages/db/src/tenant-schema.ts
export const organization = pgTable("organization", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  logo: text("logo"),
  metadata: text("metadata"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

Member

packages/db/src/tenant-schema.ts
export const member = pgTable("member", {
  id: text("id").primaryKey(),
  userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
  organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
  role: text("role", { enum: ["owner", "admin", "member", "viewer"] }).notNull().default("member"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => [
  index("member_userId_idx").on(table.userId),
  index("member_organizationId_idx").on(table.organizationId),
]);

Invitation

packages/db/src/tenant-schema.ts
export const invitation = pgTable("invitation", {
  id: text("id").primaryKey(),
  email: text("email").notNull(),
  token: text("token").notNull().unique(),
  organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
  role: text("role", { enum: ["owner", "admin", "member", "viewer"] }).notNull().default("member"),
  inviterId: text("inviter_id").notNull().references(() => user.id),
  status: text("status", { enum: ["pending", "accepted", "rejected", "expired"] }).notNull().default("pending"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  expiresAt: timestamp("expires_at").notNull(),
}, (table) => [
  index("invitation_token_idx").on(table.token),
  index("invitation_email_idx").on(table.email),
  index("invitation_organizationId_idx").on(table.organizationId),
]);

Organization middleware

The organization middleware extracts the current organization from either the x-organization-slug header or the :orgSlug path parameter, then verifies the authenticated user is a member:

apps/api/src/middleware/organization.ts
export const organizationMiddleware = async (c: Context, next: Next) => {
  const slug = c.req.header("x-organization-slug") || c.req.param("orgSlug");

  if (!slug) {
    return c.json({ error: "Organization slug is required" }, 400);
  }

  const org = await db.query.organization.findFirst({
    where: eq(organization.slug, slug),
  });
  if (!org) {
    return c.json({ error: "Organization not found" }, 404);
  }

  const user = c.get("user");
  if (!user) {
    return c.json({ error: "Unauthorized" }, 401);
  }

  const membership = await db.query.member.findFirst({
    where: and(
      eq(member.userId, user.id),
      eq(member.organizationId, org.id),
    ),
  });
  if (!membership) {
    return c.json({ error: "Not a member of this organization" }, 403);
  }

  c.set("organization", org);
  c.set("membership", membership);

  await next();
};

The middleware sets c.get("organization") and c.get("membership") on the context, available to all downstream handlers.

RBAC middleware

When RBAC is enabled, Coldstart generates a role hierarchy with four levels:

apps/api/src/middleware/rbac.ts
type Role = "owner" | "admin" | "member" | "viewer";

const ROLE_HIERARCHY: Record<Role, number> = {
  owner: 4,
  admin: 3,
  member: 2,
  viewer: 1,
};

export const requireRole = (minRole: Role) => {
  return async (c: Context, next: Next) => {
    const membership = c.get("membership");
    if (!membership || ROLE_HIERARCHY[membership.role as Role] < ROLE_HIERARCHY[minRole]) {
      return c.json({ error: `Requires ${minRole} role or higher` }, 403);
    }
    await next();
  };
};

Role capabilities

Full access to everything:

  • Delete the organization
  • Manage billing and subscription
  • Manage all members (promote, demote, remove)
  • All admin, member, and viewer permissions
Owner-only routes
app.delete("/org/:orgSlug", requireRole("owner"), deleteOrg);
app.put("/org/:orgSlug/billing", requireRole("owner"), updateBilling);

Organization management:

  • Invite and remove members
  • Update organization settings
  • All member and viewer permissions
Admin routes
app.use("/org/:orgSlug/settings/*", requireRole("admin"));
app.post("/org/:orgSlug/invitations", requireRole("admin"), createInvitation);
app.delete("/org/:orgSlug/members/:memberId", requireRole("admin"), removeMember);

Standard CRUD access:

  • Create, read, update, delete resources
  • View other members
  • All viewer permissions
Member routes
app.use("/org/:orgSlug/resources/*", requireRole("member"));
app.get("/org/:orgSlug/members", requireRole("member"), listMembers);

Read-only access:

  • View resources
  • View organization info
Viewer routes
app.get("/org/:orgSlug/resources", requireRole("viewer"), listResources);
app.get("/org/:orgSlug/resources/:id", requireRole("viewer"), getResource);

Protecting routes

Apply session middleware first

Organization middleware requires an authenticated user:

apps/api/src/routes/org.ts
import { sessionMiddleware, requireAuth } from "../middleware/session";
import { organizationMiddleware } from "../middleware/organization";
import { requireRole } from "../middleware/rbac";

Chain middleware on org routes

apps/api/src/routes/org.ts
// All org routes require auth + org membership
app.use("/org/:orgSlug/*", sessionMiddleware, requireAuth, organizationMiddleware);

// Settings require admin
app.use("/org/:orgSlug/settings/*", requireRole("admin"));

// Destructive actions require owner
app.delete("/org/:orgSlug", requireRole("owner"), deleteOrg);

Access org context in handlers

Accessing organization in a handler
app.get("/org/:orgSlug/dashboard", (c) => {
  const org = c.get("organization");
  const membership = c.get("membership");
  return c.json({
    orgName: org.name,
    userRole: membership.role,
  });
});

Invitation flow

Create invitation (admin+)

Generate a unique token and send an email:

Creating an invitation
const token = crypto.randomUUID();
await db.insert(invitation).values({
  id: crypto.randomUUID(),
  email: "new@member.com",
  token,
  organizationId: org.id,
  role: "member",
  inviterId: user.id,
  status: "pending",
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});

Accept invitation

Verify the token, create a membership, update the invitation status:

Accepting an invitation
const inv = await db.query.invitation.findFirst({
  where: and(
    eq(invitation.token, token),
    eq(invitation.status, "pending"),
  ),
});

if (!inv || inv.expiresAt < new Date()) {
  return c.json({ error: "Invalid or expired invitation" }, 400);
}

await db.insert(member).values({
  id: crypto.randomUUID(),
  userId: user.id,
  organizationId: inv.organizationId,
  role: inv.role,
});

await db.update(invitation)
  .set({ status: "accepted" })
  .where(eq(invitation.id, inv.id));

Decline invitation

Declining an invitation
await db.update(invitation)
  .set({ status: "rejected" })
  .where(eq(invitation.id, inv.id));

Per-seat billing integration

Selecting per-seat billing during scaffold automatically enables multi-tenant and RBAC. The billing system enforces seat limits when inviting new members.

With per-seat billing, the userBilling table includes a maxSeats field. Before creating an invitation or adding a member, check the seat count:

Checking seat limits
const currentMembers = await db.select({ count: count() })
  .from(member)
  .where(eq(member.organizationId, org.id));

const billing = await db.query.userBilling.findFirst({
  where: eq(userBilling.userId, org.ownerId),
});

if (currentMembers[0].count >= (billing?.maxSeats ?? 1)) {
  return c.json({ error: "Seat limit reached. Upgrade your plan." }, 403);
}

Adding after scaffold

Terminal
coldstart add tenant

This generates the tenant schema, organization middleware, and RBAC middleware. Run migrations afterward:

Terminal
pnpm -F @scope/db db:generate && pnpm -F @scope/db db:migrate

On this page