Organizations, teams, workspaces
The model
Section titled “The model”| Entity | Owned by | What it is |
|---|---|---|
organization |
Better Auth | The tenant and billing entity. Owns members, invitations and teams. |
team |
Better Auth | Groups members inside an organization. |
workspace |
Starterdough | The product container, scoped to an organization and optionally to a team. |
Plans and limits live in packages/billing. The Stripe plugin bills the organization and syncs
seats with membership (see Billing).
New sessions start in the user’s first organization. The sidebar switcher changes the active one.
Routes: /app (workspaces of the active organization), /app/[workspace],
/app/[workspace]/settings, /app/organization (settings, usage, audit log), /app/organization/new,
/app/team (members, invitations, teams), /accept-invitation/[id].
Roles, defined once
Section titled “Roles, defined once”Three roles (owner, admin, member) and the permission statements behind them are defined in
packages/auth/src/permissions.ts (browser-safe) and consumed in three places:
- the auth server (
organization({ ac, roles })); - the auth client:
checkRolePermission()gates UI, so members do not see buttons they cannot use; - the API router:
requireMember(userId, organizationId, permissions)on every workspace procedure, answeringFORBIDDENto non-members.
Adding a resource is one statement plus role edits in one file. Custom or dynamic roles are not enabled.
Invitations and teams
Section titled “Invitations and teams”Invitations are emailed as /accept-invitation/<id> links and expire after 7 days. The landing
page asks the recipient to sign in or sign up first. An expired invitation stops counting against
the plan’s seat limit as soon as it lapses, and the daily purge (03:17 UTC) deletes it. Teams are
created and managed from /app/team. A workspace can be scoped to a team.
Workspaces
Section titled “Workspaces”Workspaces have a name and a slug that is unique per organization. Slugs are lowercase letters,
numbers and dashes, 2 to 48 characters, and may not shadow the app’s own routes under /app/
(admin, api, billing, new, organization, settings, team). The create and update forms
validate the same schema the API enforces: WorkspaceCreateInputSchema and
WorkspaceUpdateInputSchema from the contract.
Deleting a workspace deletes its documents’ stored objects as well as their rows. Deleting an organization does the same across its whole key prefix, and so does deleting an account whose solo organization goes with it.
Procedures: workspaces.list, get, create, update, delete. See
API: RPC and REST.
Plan limits
Section titled “Plan limits”Limits from the plan catalog are enforced server-side: workspaces, AI credits and document
storage in the router, seats in the organization hooks. Exceeding one returns LIMIT_REACHED
(HTTP 403) with { kind: 'workspaces' | 'members' | 'aiCredits' | 'storageBytes', limit, plan },
and the app shows a message with a link to billing. organizations.usage reports the plan, its
limits and the current counts. Storage is per workspace and reported by workspaces.get and
documents.list instead. With billing off, plan and limits are null and nothing is capped.
Each check and the insert it guards run inside one transaction holding
pg_advisory_xact_lock(hashtext(organizationId)), so parallel requests cannot all read the
pre-insert count and all pass. Slug uniqueness sits on a unique index on top of that.
Audit log
Section titled “Audit log”The audit_log table records who did what per organization: every workspace mutation (from the
router) and every organization mutation (from a Better Auth hooks.after middleware, which sees
the acting session). Owners and admins read it with organizations.auditLog on
/app/organization, newest first, paged with an opaque cursor (hand back the previous answer’s
nextCursor). Subscription changes driven by Stripe webhooks are audited without an actor.
Organization deletion is not audited, because the entry would reference a deleted organization.
An action taken during an impersonation session records both identities: the impersonated user
as the actor, and the platform administrator in impersonated_by, which organizations.auditLog
returns as impersonatedBy. AUDIT_LOG_RETENTION_DAYS (365 by default, 0 = for ever) bounds how
long entries live. The table stores actor_email, so retention also bounds how long a deleted
account’s address survives.