Trovella Wiki

Membership

How users are linked to organizations through member records, roles, and the member tRPC router.

A membership links a user to an organization with a specific role. The member table is the central record that authorizedProcedure reads on every request to determine what the user can do.

The Member Table

For the full schema definition, see Data & Storage -- Schema Design. The key columns are:

ColumnTypePurpose
idtext (PK)Better Auth-generated ID
organizationIdtext (FK -> organization)Which organization this membership belongs to
userIdtext (FK -> user)Which user holds the membership
roletext (default: "member")One of owner, admin, or member
createdAttimestampWhen the membership was created

The member table has RLS enabled via tenantPolicies("member", "organization_id"). This means member records are only visible within the context of the organization they belong to. A user querying members from organization A cannot see members from organization B, even if they are a member of both.

Roles

RoleCreated ByPermissions
ownerSystem (bootstrapping) or Better Auth org creationFull access (manage all). Cannot be demoted or removed.
adminOwner promoting a memberRead/update org, full member management, invitation management
memberInvitation acceptance or admin assignmentRead-only on org/members/invitations. Full access to research features.

The role-to-permission mapping is defined in defineAbilityFor in packages/api/src/abilities/define-ability.ts. For the complete matrix, see Identity & Access -- Authorization.

Unknown roles (any string not matching owner, admin, or member) default to read-only access on Organization and Member subjects.

Member Router

Location: packages/api/src/routers/member.ts

The member router provides three endpoints, all using authorizedProcedure:

member.list

Lists all members of the active organization, including basic user information (id, name, email, image).

  • CASL check: ability.cannot("read", "Member") -- all roles can read
  • RLS: The query runs inside withTenantContext, so only members of the active org are returned
  • Includes: Joined user record with id, name, email, image

member.updateRole

Changes a member's role. Validates against two constraints:

  1. CASL check: ability.cannot("update", "Member") -- requires admin or owner role
  2. Owner protection: If the target member's current role is "owner", the mutation is rejected with "Cannot change an owner's role"

The input schema (updateMemberRoleSchema from @repo/validators) restricts the new role to "admin" or "member" -- you cannot promote someone to "owner" through this endpoint.

// packages/validators/src/member.ts
export const updateMemberRoleSchema = z.object({
  memberId: idSchema,
  role: z.enum(["admin", "member"]),
});

member.remove

Removes a member from the organization. Two paths to removal:

  1. Self-removal: Any member can remove themselves (target.userId === ctx.session.user.id), bypassing the CASL check
  2. Admin removal: Users with delete permission on Member (admins and owners) can remove other non-owner members

Owner protection: Owners cannot be removed. This prevents an organization from losing its last owner.

How Membership Connects to Authorization

On every authorizedProcedure request, the middleware:

  1. Looks up the member record for the current user in the active organization
  2. Reads the organization type
  3. Builds a CASL ability from { userId, role, orgType }
  4. Attaches ctx.member and ctx.ability to the context

If no member record is found, the request fails with FORBIDDEN. This means a user who is not a member of an organization cannot make any tenant-scoped requests against it, even if they somehow set activeOrganizationId to that organization's ID.

For the full middleware chain, see Data & Storage -- Procedure Chain.

Invitations (Not Yet Implemented)

The invitation table exists in the schema with RLS enabled, and CASL defines Invitation as a subject, but no invitation router has been built yet. This is deferred until family/company organization creation is added. The schema is in place so that:

  • The database migration is already done
  • CASL rules already account for invitation permissions
  • The invitation table is already tenant-scoped via tenantPolicies

When invitations are implemented, they will follow the same pattern as the member router: authorizedProcedure with CASL checks, operating on the tenant-scoped ctx.db.

On this page