Skip to content

Organizations

CruzJS supports multi-tenant applications through organizations. Each org has its own members, roles, settings, and data scope. The @cruzjs/start package provides OrgService, MemberService, and InvitationService for managing the full org lifecycle.

Use the org.create tRPC mutation. The creator is automatically assigned the OWNER role:

const org = await trpc.org.create.mutate({
name: 'My Team',
slug: 'my-team', // optional -- auto-generated from name if omitted
avatarUrl: 'https://...', // optional
settings: { timezone: 'America/New_York' }, // optional JSON
});
// org.id -- UUID
// org.slug -- URL-safe slug (unique, auto-suffixed if collision)

Under the hood, OrgService.createOrg() generates a unique slug, inserts the organization row, and creates an orgMembers entry with role OWNER:

// Internal flow
const org = await orgService.createOrg(
{ name: 'My Team', slug: 'my-team' },
ownerId
);

Slugs are URL-safe identifiers auto-generated from the org name. If a slug collision occurs, a numeric suffix is appended (e.g., my-team-1). You can also provide a custom slug. The org.getBySlug query lets you look up orgs by slug:

const org = await trpc.org.getBySlug.query({ slug: 'my-team' });

Most org-scoped operations use orgProcedure, which resolves the org from route params or the X-Organization-ID header and verifies the user is a member:

import { orgProcedure, router } from '@cruzjs/core/trpc/context';
export const myRouter = router({
list: orgProcedure.query(async ({ ctx }) => {
// ctx.org.org.orgId -- the current org ID
// ctx.org.org.userId -- the authenticated user ID
// ctx.org.org.role -- the user's role in this org
// ctx.org.user.id -- same as userId
}),
});

The org context middleware (requireOrgContext) extracts the org ID from:

  1. Route params (:orgId parameter)
  2. X-Organization-ID request header

It then verifies the org exists (and is not soft-deleted) and that the user is a member. If either check fails, a 400/403/404 response is thrown.

import { requireSession } from '@cruzjs/core/shared/middleware/session.middleware';
import { requireOrgContext } from '@cruzjs/core/shared/middleware/org-context.middleware';
export async function loader({ request, params }: LoaderFunctionArgs) {
const auth = await requireSession(request);
const orgCtx = await requireOrgContext(request, params, auth);
// orgCtx.org.orgId, orgCtx.org.role available
}

Use getOrgContext instead of requireOrgContext if you want a nullable result rather than a thrown response:

const orgCtx = await getOrgContext(request, params, auth);
if (orgCtx) {
// user is in an org
}

The session stores a currentOrgId field. Update it via SessionService.updateCurrentOrg():

const sessionService = container.get<SessionService>(SessionService);
await sessionService.updateCurrentOrg(sessionToken, newOrgId);

This updates both the KV cache and the D1 record. The auth.session query returns each org with an isCurrent flag so the UI can display the active org.

All org-scoped database tables include an orgId foreign key. Use the org context to scope queries:

// In an orgProcedure handler
const items = await db
.select()
.from(myTable)
.where(eq(myTable.orgId, ctx.org.org.orgId));

See the Data Ownership guide for patterns on user-scoped vs. org-scoped data.

const members = await trpc.member.list.query();
// Returns: { id, orgId, userId, role, createdAt, user: { id, name, email, avatarUrl } }[]

Requires member:write permission. Prevents demoting the last OWNER:

await trpc.member.updateRole.mutate({
userId: memberId,
role: 'ADMIN', // OWNER | ADMIN | MEMBER | VIEWER
});

Requires member:delete permission. Prevents removing the last OWNER:

await trpc.member.remove.mutate({ userId: memberId });

Members can leave via self-service. Owners cannot leave (must transfer ownership first), and users cannot leave their last organization:

await trpc.member.leave.mutate({ orgId });

Members with member:write permission can invite new users by email. Invitations expire after 7 days.

await trpc.invitation.create.mutate({
email: 'newuser@example.com',
role: 'MEMBER',
});

This generates a secure token, stores the hashed token in D1, and queues an invitation email with accept/decline links. If the email already belongs to a member, the request is rejected. If a pending non-expired invitation already exists for that email, a duplicate error is returned.

await trpc.invitation.accept.mutate({ token: invitationToken });

If the invited email does not match an existing identity, a new identity is created without a password (the user can set one via password reset). The user is added to the org with the specified role, and the invitation is deleted.

// Invitee declines
await trpc.invitation.decline.mutate({ token: invitationToken });
// Org admin cancels
await trpc.invitation.cancel.mutate({ invitationId });
const invitations = await trpc.invitation.list.query();
// Returns non-expired invitations for the current org

Organizations have a JSON settings column for arbitrary configuration:

await trpc.org.update.mutate({
settings: {
timezone: 'America/Chicago',
features: { advancedReporting: true },
},
});

Settings are stored as a JSON string in D1 and parsed/stringified automatically by OrgService.

OrgService.deleteOrg() performs a soft delete by setting the deletedAt timestamp. Soft-deleted orgs are excluded from all queries by default. Requires org:delete permission (typically OWNER only).

EndpointTypeAuthPermissionDescription
org.listqueryprotectedList user’s orgs
org.createmutationprotectedCreate new org
org.getqueryorgProcedureorg:readGet org with stats
org.getBySlugqueryprotectedmembershipGet org by slug
org.updatemutationorgProcedureorg:writeUpdate org
org.deletemutationorgProcedureorg:deleteSoft-delete org