Skip to content
TALA
Esc
navigateopen⌘Jpreview
On this page

Organizations

How OrganizationGuard resolves org context on every request, the two unrelated role systems, and the hashed-token invitation flow end to end.

Every user is auto-provisioned a personal organization. Multi-tenant scoping is therefore always on — there is no “no organization” state for a verified account.

How context is resolved

OrganizationGuard runs immediately after AuthGuard on every tenant-facing controller.

const requestedOrgId = req.headers["x-organization-id"] as string | undefined;

const membership = requestedOrgId
  ? await this._memberRepo.findMembership(requestedOrgId, user.id)
  : await this._memberRepo.findActiveByUser(user.id);

if (!membership || membership.status !== MembershipStatus.ACTIVE)
  throw new ForbiddenException("You do not belong to an active organization");

req.orgContext = {
  organizationId: membership.organization.id,
  role: membership.role,
};

Three consequences follow:

  • The header is optional. Omit it and the guard falls back to the caller’s single active membership.
  • ACTIVE is required either way. An INVITED or SUSPENDED membership fails the guard, so a pending invitee cannot read org data by guessing an ID.
  • The ID comes from the membership, not the header. Both lookups eager-load the organization and the guard reads the ID off the membership row, so a header value that does not match a real membership can never leak into orgContext.

Controllers read it through the @CurrentOrg() decorator:

@Get()
list(@CurrentUser() user: User, @CurrentOrg() org: OrgContext) {
  return this._assetService.listScoped(user.id, org.organizationId, /* … */);
}

Client side

The SPA persists the active organization and attaches the header automatically:

const organizationId = getActiveOrganizationId();
if (organizationId && !headers.has("x-organization-id"))
  headers.set("x-organization-id", organizationId);

setActiveOrganizationId() also dispatches a tala:organization-change window event, which is how OrganizationSwitcher tells the rest of the app to refetch.

Two role systems

Role and OrgRole are unrelated and gate different things.

Role OrgRole
Scope platform single organization
Values CONTENT_CREATOR, ADMIN OWNER, ADMIN, MEMBER
Enforced by RoleGuard + @Roles() OrganizationRoleGuard + @OrgRoles()
Gates /api/timeline admin routes, /api/seeder membership mutations, org rename

A platform ADMIN has no special power inside someone else’s organization.

Role requirements per route

Route Required OrgRole
GET /api/organizations — (lists the caller’s own memberships)
POST /api/organizations
GET /api/organizations/:id any active member
GET /api/organizations/:id/members any active member
PATCH /api/organizations/:id OWNER
POST /api/organizations/:id/invitations OWNER or ADMIN
PATCH /api/organizations/:id/members/:memberId OWNER or ADMIN
DELETE /api/organizations/:id/members/:memberId OWNER or ADMIN

Invitations

Invite

POST /api/organizations/:id/invitations
Content-Type: application/json

{ "email": "new@example.com", "role": "MEMBER" }

Creates an organization_invitations row holding a sha256 hash of the token, the inviter, the role, and an expiry. The raw token only ever exists in the email.

Look up

GET /api/organizations/invitations/:token

Requires an authenticated user. Returns the invitation with its organization so the SPA can render “you have been invited to X” before the invitee commits.

Accept

POST /api/organizations/invitations/:token/accept

Creates the organization_members row and stamps accepted_at.

Invitations carry accepted_at and revoked_at separately, so a revoked invitation is distinguishable from an unused one.

A caller without the required OrgRole gets 403 from OrganizationRoleGuard; the request never reaches the controller.

What goes wrong

Every one of these is a real path in organization.service.ts, and the SPA has to distinguish them because they need different messages.

Situation Status Message
Invitation token unknown, already accepted, revoked, or expired 404 Invitation is invalid or expired
Accepting while signed in as a different user 403 Invitation email does not match your account
Accepting would exceed the plan’s seat limit 409 Organization has reached its active seat limit
Inviting someone who is already a member 409 User is already a member
Changing or removing the organization owner 400 Organization owners cannot be changed / ...cannot be removed
Member ID not in this organization 404 Member not found
Organization not found, or caller is not a member 404 Organization not found

The seat limit is the owner’s plan maximumSeat, defaulting to 1 if the owner has no plan. Only ACTIVE members count, so outstanding invitations do not consume seats until accepted — which means an invitation can be sent successfully and then fail at accept time. Handle 409 on accept, not just on invite.

Two endpoints with no consumer

GET /api/organizations/:id and PATCH /api/organizations/:id are both built and unused. Org detail is currently read out of the list response instead, which means renaming an organization is unreachable from the UI. See what’s wired.

Was this page helpful?