---
title: Organizations
description: 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.

```ts title="backend/src/organization/guards/organization.guard.ts"
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:

```ts
@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:

```ts title="frontend/src/utils/api.ts"
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

1. **Invite**

    ```http
    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.

2. **Look up**

    ```http
    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.

3. **Accept**

    ```http
    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` |

:::warning[Four different invitation problems return the same 404]
Unknown, accepted, revoked, and expired are collapsed into one status and one message on purpose — the endpoint is reachable by anyone holding a token, so distinguishing them would leak whether a token was ever valid. The SPA cannot tell the user *why*; "this invitation is no longer valid — ask for a new one" is the honest message.
:::

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.

:::note[Seats count `ACTIVE` only]
`MembershipStatus.INVITED` does not consume a seat against the plan's `minimum_seat` / `maximum_seat`. Only `ACTIVE` members do.
:::

## 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](/status/integration).
