Workspaces, teams & tenancy
How Orbit scopes every row, every permission, and every realtime channel.
A workspace is Orbit's tenancy root. Every domain row that isn't a user sits inside exactly one workspace, and every API route resolves a workspace early — via /v1/workspaces/:slug — so the rest of the request can assume its scope without re-proving it. Teams, when the feature is on, nest inside a workspace: they're a narrower permission scope, never a tenant of their own.
The three aggregates
| Aggregate | Key | What it is |
|---|---|---|
Workspace | id | The tenant root. Has a globally unique slug (shows up in URLs), a name, and an ownerId. Holds no application data itself — it's the anchor others hang off. |
WorkspaceMember | (workspaceId, userId) | One row per user × workspace pair. Holds an inlined WorkspaceMemberRoleSnapshot so authorization is an O(1) set lookup with no extra repository hop. |
WorkspaceInvite | token | A pending invitation. Points at an email and optionally a role id. Becomes a WorkspaceMember when accepted. |
The shape of a member
WorkspaceMember is the aggregate you'll touch most often — it carries the role snapshot, which is what the permission guards read:
apps/api/src/workspaces/domain/workspace-member.ts
export interface WorkspaceMemberRoleSnapshot { id: WorkspaceRoleId; systemKey: WorkspaceRoleSystemKey | null; // OWNER | ADMIN | MEMBER | null permissions: readonly WorkspacePermission[];}
export class WorkspaceMember { static join(input: { workspaceId, userId, role, seed }, clock): WorkspaceMember; changeRole(next: WorkspaceMemberRoleSnapshot, changedByMemberId): void; hasPermission(p: WorkspacePermission): boolean; leave(): void;}Each mutation enqueues a domain event onto the aggregate — WorkspaceMemberJoined, WorkspaceMemberRoleChanged, WorkspaceMemberLeft — collected by the Unit of Work and dispatched after commit. The realtime publisher listens and pushes DTOs to every socket on the workspace channel.
How teams fit in
Teams are an optional feature (--no-teams strips them at scaffold time). When on, they add a parallel aggregate family under apps/api/src/teams/:
Team— keyed byid, belongs to a workspace, has its own slug scoped within that workspace.TeamMember— keyed by(teamId, workspaceMemberId). A team member must already be a workspace member; teams narrow scope, they never widen it.TeamRole— system rolesTEAM_ADMINandTEAM_MEMBER, plus any custom roles. Permissions are exclusivelyteam.*— see the PBAC page.
The nesting only goes one level deep. There are no sub-teams, and there's no team-of-teams. The assumption is that "workspace" is your billing/admin boundary and "team" is your organizational boundary inside it.
URLs and scoping
Every authenticated route on the API lives under /v1/workspaces/:slug/.... The session middleware resolves the slug to a workspace and the caller's WorkspaceMember once, stashes both on the context, and every downstream handler reads them from there — no handler looks up a workspace by itself.
A typical request path
GET /v1/workspaces/demo/members ↓ session middleware resolves user from cookie resolves workspace "demo" resolves WorkspaceMember (user, workspace) fails with 404 if the user isn't a member ↓ controller reads c.get("me") — no extra round-tripOn the web side, the router mirrors this: the authenticated shell's root route is /d/$workspaceSlug, and everything inside is workspace-scoped. The store layer (workspace-stores.ts) keys every realtime cache by workspace, so switching workspaces is a full reset, not a filter.
Invites and joining
The invite flow is intentionally one-shot:
- A member with
workspace.members.invitecreates aWorkspaceInviterow. The service emits aWorkspaceInvitedevent post-commit. - A projector listens for that event and asks the Mailer port to send an email with a link to
${WEB_ORIGIN}/invites/accept?token=.... - The recipient clicks, signs in if they haven't already, and
POST /v1/invites/acceptswaps the invite for aWorkspaceMemberon the embedded role.
Accept is idempotent — replaying a token that's already been consumed returns the existing membership instead of 400'ing. Keeps double-clicks from breaking the flow.
The owner special-case
The ownerId column on Workspace is load-bearing: the OWNER system role is permission-locked to the full workspace-permission set, and workspace owners bypass all team.* guards outright (see TeamsController.requireTeamPermission). You can demote an admin; you can't demote the owner without transferring ownership first.