Workspaces and teams
A workspace is what a customer’s data belongs to: a company, or one person. In the database
and in Better Auth it is an organization. Everything your product stores belongs to a
workspace, never directly to a user.
Personal accounts or teams
Section titled “Personal accounts or teams”teams in packages/contracts/src/app.ts picks the kind of product. It is false by
default.
| Teams off (personal accounts) | Teams on | |
|---|---|---|
| Workspaces | Exactly one per person, created on their first visit, never shown | Created by people, as many as they want |
| Other people | None: invitations are refused with Teams are turned off |
Invited by email, with a role |
| Addresses | /app/settings/billing |
/w/acme/settings/billing |
| Pricing | Flat | Flat or per seat |
With teams off the API creates the workspace in WorkspacesService when /api/me is first
called, with the slug user-<userId>, and the person is its owner. The web app still routes
through /w/$slug; a rewrite in apps/web/src/lib/personal-urls.ts shows it as /app, with
me standing in for the slug. That is why one set of screens serves both modes, and why links
must be written as /w/$slug/..., never as /app.
You can turn teams on later. Data already belongs to workspaces, so nothing moves: each
person’s personal workspace becomes a workspace they own, at /w/user-<id>/..., which they can
rename and invite people to. Links saved under /app stop working. Turning teams off again
is not supported once people share workspaces: there is no way to decide whose a shared
workspace becomes.
How isolation is enforced
Section titled “How isolation is enforced”Two layers, and both are needed.
The guard decides who reaches the route. A controller marked @WorkspaceScoped() takes
the workspace from :workspaceId in the URL and looks up the caller’s membership. A caller who
is not a member gets 404 Workspace not found, exactly as for a workspace that does not exist,
so ids cannot be probed. The workspace, the caller’s role and the plan in effect are then
available through @CurrentWorkspace(). The id never comes from a request body or query
string, where it could be anyone’s.
The query decides which rows come back. The guard cannot see your SQL. Every query on
workspace data filters by organizationId, including updates and deletes by id. Without it, a
member of one workspace could reach another’s row by guessing its id through their own
workspace’s route. Tables of workspace data have organizationId referencing organizations.id
with onDelete: 'cascade', so deleting a workspace deletes its data.
AdminService is the one place that reads across workspaces, for the operators’ admin panel.
Roles are ordered owner > admin > member; @RequireRole('admin') lets owners through
too. What each can do as the kit ships:
| Member | Admin | Owner | |
|---|---|---|---|
| Use the product’s screens | ✓ | ✓ | ✓ |
| See who is in the workspace | ✓ | ✓ | ✓ |
| Invite people, cancel invitations | ✓ | ✓ | |
| Remove people, change roles | ✓, not owners | ✓ | |
| Make someone an owner | ✓ | ||
| Rename the workspace | ✓ | ✓ | |
| Read the audit log (on plans with it) | ✓ | ✓ | |
| Create and revoke API keys | ✓ | ✓ | |
| See and change billing | ✓ | ||
| Delete the workspace | ✓ |
Membership rules come from Better Auth’s organization plugin; billing and the audit log from
@RequireRole() on their controllers. A workspace always keeps at least one owner: the last
owner cannot leave or be demoted. With teams off, the only role anyone has is owner of their
own workspace.
Being an operator of the whole product is separate: it comes from ADMIN_EMAILS, not from any
workspace role. See Admin panel.
Invitations and seats
Section titled “Invitations and seats”A plan’s maxSeats caps how many members a workspace can have. Open invitations hold a seat,
so a workspace cannot invite past its limit and then accept them all; inviting the same address
again replaces the old invitation rather than taking a second seat. On per-seat plans, each
change of membership updates the quantity with the payment provider. See Billing.