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.
ACTIVEis required either way. AnINVITEDorSUSPENDEDmembership 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/:tokenRequires 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/acceptCreates 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.
