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.
Creating an organization
Section titled “Creating an organization”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 flowconst org = await orgService.createOrg( { name: 'My Team', slug: 'my-team' }, ownerId);Org slugs
Section titled “Org slugs”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' });Organization context (orgProcedure)
Section titled “Organization context (orgProcedure)”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:
- Route params (
:orgIdparameter) X-Organization-IDrequest 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.
Using org context in React Router loaders
Section titled “Using org context in React Router loaders”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}Switching organizations
Section titled “Switching organizations”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.
Org-scoped data isolation
Section titled “Org-scoped data isolation”All org-scoped database tables include an orgId foreign key. Use the org context to scope queries:
// In an orgProcedure handlerconst 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.
Member management
Section titled “Member management”Listing members
Section titled “Listing members”const members = await trpc.member.list.query();// Returns: { id, orgId, userId, role, createdAt, user: { id, name, email, avatarUrl } }[]Updating a member’s role
Section titled “Updating a member’s role”Requires member:write permission. Prevents demoting the last OWNER:
await trpc.member.updateRole.mutate({ userId: memberId, role: 'ADMIN', // OWNER | ADMIN | MEMBER | VIEWER});Removing a member
Section titled “Removing a member”Requires member:delete permission. Prevents removing the last OWNER:
await trpc.member.remove.mutate({ userId: memberId });Leaving an organization
Section titled “Leaving an organization”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 });Invitations
Section titled “Invitations”Members with member:write permission can invite new users by email. Invitations expire after 7 days.
Sending an invitation
Section titled “Sending an invitation”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.
Accepting an invitation
Section titled “Accepting an invitation”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.
Declining or canceling
Section titled “Declining or canceling”// Invitee declinesawait trpc.invitation.decline.mutate({ token: invitationToken });
// Org admin cancelsawait trpc.invitation.cancel.mutate({ invitationId });Listing pending invitations
Section titled “Listing pending invitations”const invitations = await trpc.invitation.list.query();// Returns non-expired invitations for the current orgOrg settings
Section titled “Org settings”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.
Soft deletion
Section titled “Soft deletion”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).
tRPC endpoints
Section titled “tRPC endpoints”| Endpoint | Type | Auth | Permission | Description |
|---|---|---|---|---|
org.list | query | protected | — | List user’s orgs |
org.create | mutation | protected | — | Create new org |
org.get | query | orgProcedure | org:read | Get org with stats |
org.getBySlug | query | protected | membership | Get org by slug |
org.update | mutation | orgProcedure | org:write | Update org |
org.delete | mutation | orgProcedure | org:delete | Soft-delete org |