Skip to main content

Overview

BunShip uses an organization-based multi-tenancy model. Every resource in the system — projects, webhooks, API keys, subscriptions, audit logs — belongs to an organization. Users access resources through their organization membership, and each membership carries a role that determines what the user can do.

Data Model

Three database tables form the foundation of the multi-tenant system:
// packages/database/src/schema/organizations.ts
export const organizations = sqliteTable("organizations", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  description: text("description"),
  logoUrl: text("logo_url"),
  settings: text("settings", { mode: "json" }).$type<{
    branding?: { primaryColor?: string; accentColor?: string };
    features?: { webhooks?: boolean; apiAccess?: boolean; customDomain?: boolean };
    billing?: { email?: string; taxId?: string };
  }>(),
  createdBy: text("created_by").notNull().references(() => users.id),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
  deletedAt: integer("deleted_at", { mode: "timestamp" }),
});
Organizations support soft deletion via deletedAt. All queries filter out soft-deleted organizations automatically.

Creating an Organization

When a user creates an organization, they automatically become the owner:
// POST /api/v1/organizations
const { data } = await api.api.v1.organizations.post({
  name: "Acme Corp",
  slug: "acme-corp",
  description: "Building the future of widgets",
});

// data.organization.id   -- the new organization ID
// data.organization.slug -- URL-safe identifier
The creation process:
  1. Validate the name and slug (slug must be unique)
  2. Insert the organization record
  3. Create a membership with role: "owner" for the creating user
  4. Return the organization with the user’s membership

Configuration Limits

ParameterDefault
Allow multiple organizations per userYes
Max organizations per user10
Require organization on signupNo
Allow user-initiated org creationYes
These are set in featuresConfig.organizations and can be adjusted:
organizations: {
  enabled: true,
  allowMultipleOrgs: true,
  allowOrgCreation: true,
  requireOrgOnSignup: false,
  maxOrgsPerUser: 10,
}

Team Management

Inviting Members

Users with the members:invite permission can invite new team members by email:
// POST /api/v1/organizations/:orgId/invitations
const { data } = await api.api.v1.organizations[":orgId"].invitations.post({
  email: "[email protected]",
  role: "member",
});
The invitation flow:
1

Send invitation

An admin or owner sends an invitation specifying the email and desired role. The API generates a secure token, hashes it, and stores the invitation record.
2

Email delivered

BunShip sends a transactional email with an invitation link containing the plaintext token.
3

Accept invitation

The invitee clicks the link and calls the accept endpoint. If they already have an account, they are added to the organization. If not, they register first, then accept.
// POST /api/v1/invitations/accept
await api.api.v1.invitations.accept.post({
  token: "invitation-token-from-email",
});
4

Membership created

A membership record is created with the role specified in the invitation. The invitation’s acceptedAt timestamp is set.

Roles

Every membership carries one of four roles. Roles determine what permissions the user has within the organization. See Permissions for the complete permission matrix.
RoleDescription
OwnerFull control. Can delete the organization, transfer ownership, and manage billing. Every org has exactly one owner.
AdminCan manage members, invitations, projects, webhooks, API keys, and view audit logs. Cannot delete the org or manage billing.
MemberCan read organization details, view team members, and fully manage projects. No access to admin-level features.
ViewerRead-only access to organization details, members list, and projects. Cannot create or modify anything.

Updating Roles

Users with the members:update permission can change another member’s role:
// PATCH /api/v1/organizations/:orgId/members/:memberId
await api.api.v1.organizations[":orgId"].members[":memberId"].patch({
  role: "admin",
});
The owner role cannot be assigned through the role update endpoint. Use the ownership transfer endpoint instead.

Removing Members

Users with the members:remove permission can remove team members:
// DELETE /api/v1/organizations/:orgId/members/:memberId
await api.api.v1.organizations[":orgId"].members[":memberId"].delete();
The organization owner cannot be removed. To change ownership, transfer it to another member first.

Organization-Scoped Data

All resources in BunShip are scoped to an organization through a foreign key:
organizations
├── memberships (users belong to orgs)
├── invitations (pending team invites)
├── subscriptions (Stripe billing)
├── projects (your domain resources)
├── webhooks (outgoing webhook endpoints)
│   └── webhook_deliveries (delivery history)
├── api_keys (integration keys)
├── audit_logs (activity history)
└── files (uploaded assets)
This scoping ensures complete data isolation between tenants. A user in Organization A can never access data belonging to Organization B, even if they have the same user account.

Organization Middleware

The organizationMiddleware runs on every organization-scoped route. It performs two database lookups in sequence:
  1. Load the organization by the :orgId URL parameter, filtering out soft-deleted records
  2. Load the user’s membership to confirm they belong to this organization
// apps/api/src/middleware/organization.ts
export const organizationMiddleware = new Elysia({ name: "organization" }).derive(
  { as: "scoped" },
  async ({ params, store, set }) => {
    const orgId = (params as { orgId?: string }).orgId;
    const user = (store as { user?: { id: string } }).user;

    if (!user) {
      set.status = 401;
      throw new Error("Authentication required");
    }

    if (!orgId) {
      set.status = 400;
      throw new Error("Organization ID required");
    }

    const db = getDatabase();

    const organization = await db.query.organizations.findFirst({
      where: and(eq(organizations.id, orgId), isNull(organizations.deletedAt)),
    });

    if (!organization) {
      set.status = 404;
      throw new NotFoundError("Organization");
    }

    const membership = await db.query.memberships.findFirst({
      where: and(eq(memberships.userId, user.id), eq(memberships.organizationId, orgId)),
    });

    if (!membership) {
      set.status = 403;
      throw new AuthorizationError("Not a member of this organization");
    }

    return { organization, membership };
  }
);
After this middleware runs, downstream handlers and permission middleware can access organization and membership from the context without additional database queries.

Organization Settings

Each organization has a JSON settings column that stores optional configuration:
settings: {
  branding: {
    primaryColor: "#5046E5",
    accentColor: "#818CF8",
  },
  features: {
    webhooks: true,
    apiAccess: true,
    customDomain: false,
  },
  billing: {
    email: "[email protected]",
    taxId: "US123456789",
  },
}
These settings can be used to customize the behavior of features per organization — for example, enabling or disabling webhook access based on the organization’s subscription tier.

API Endpoints

MethodEndpointPermissionDescription
GET/api/v1/organizationsAuthenticatedList user’s organizations
POST/api/v1/organizationsAuthenticatedCreate organization
GET/api/v1/organizations/:orgIdorg:readGet organization details
PATCH/api/v1/organizations/:orgIdorg:updateUpdate organization
DELETE/api/v1/organizations/:orgIdorg:deleteDelete organization
GET/api/v1/organizations/:orgId/membersmembers:readList members
PATCH/api/v1/organizations/:orgId/members/:idmembers:updateUpdate member role
DELETE/api/v1/organizations/:orgId/members/:idmembers:removeRemove member
GET/api/v1/organizations/:orgId/invitationsinvitations:readList invitations
POST/api/v1/organizations/:orgId/invitationsinvitations:createSend invitation
DELETE/api/v1/organizations/:orgId/invitations/:idinvitations:deleteCancel invitation