This document was written by AI and has been manually reviewed.
Teams
A team is a shared owner for OAuth applications and verified domains. Members can manage the team's apps and domains in the same UI as their personal resources, while ownership of those resources cleanly survives any single member leaving the team.
Roles
| Role | Can manage members | Can edit team settings | Can manage apps/domains | Can transfer ownership | Can disband |
|---|---|---|---|---|---|
owner | yes | yes | yes | yes (to a co-owner) | yes |
co-owner | yes (except owner) | yes | yes | no | no |
admin | yes (member only) | no | yes | no | no |
member | no | no | yes (read; write apps the team allows) | no | no |
There is exactly one owner per team. Transferring ownership is a single, audited operation; the previous owner is demoted to co-owner.
Joining a team
There are three ways to join:
- Direct add by an admin/co-owner/owner from Teams → <team> → Members → Add member.
- Invite link generated from Members → Generate invite. Optional email lock, max-uses cap, and expiry. Visiting
/teams/join/:tokenshows the team profile and any unmet requirements before accepting. - API —
POST /api/teams/join/:tokenwith a session bearer.
Join requirements
Team owners can require members to satisfy security factors before joining (and again whenever a member tries to drop below the bar). Admins can also enforce a site floor — a minimum every team is forced to require, regardless of the team-level flag.
| Requirement | Team flag | Site floor key |
|---|---|---|
| At least one TOTP authenticator or passkey | teams.require_2fa | default_team_require_2fa |
| Verified primary email | teams.require_verified_email | default_team_require_verified_email |
Effective requirement = team flag OR site floor. Owners can opt their team in further than the floor but cannot opt out below it. The team-settings UI greys out the toggle for any factor forced by the site.
Retroactive enforcement
Turning a requirement on flips it for every existing member immediately. Any member who hasn't enrolled the factor is locked out of team operations until they do. The unmetRequirements helper surfaces this on the join confirmation screen and on the user-side mutation paths (e.g. removing the last TOTP authenticator) so members get a clear error before data is changed. Notify members before flipping a requirement on a populated team.
The user-facing join flow at /teams/join/:token shows the requirements first and links to Profile → Security / Profile → Email to satisfy them. The endpoint payload includes:
{
"team": { "id": "...", "name": "Acme", "avatar_url": "..." },
"requirements": {
"require_2fa": true,
"require_verified_email": true,
"forced_by_site": { "require_2fa": false, "require_verified_email": true }
},
"unmet": ["2fa"]
}Sub-teams (nested teams)
A team can be created under another team to mirror an organisation's internal structure. Sub-teams nest recursively up to the operator-configured max_team_depth (default 5 levels); the server rejects cycles and over-depth re-parents on both create and move.
The entire feature is gated behind site config — operators can turn it off, tighten the depth cap, or toggle the inheritance semantics independently. See Configurability below.
Inheritance — what flows down
Sub-teams are not isolated copies. Two things automatically flow from an ancestor to every team underneath it (each can be turned off independently in site config):
- Membership (
inherit_team_membership, default on) — a member of team A has at least their A-role on every descendant of A. A direct membership on a sub-team stacks on top: the effective role ismax(direct, inherited). Useful for "department head is an admin of every project sub-team" without duplicating rows. When the toggle is off,getEffectiveMemberdegenerates to a direct-only lookup and listings stop expanding into sub-team subtrees. - Verified domains (
inherit_team_domains, default on) — every domain owned by an ancestor is visible to sub-teams as a read-only entry tagged withinherited_from. This lets a sub-team auto-verify a sub-domain when the parent already owns the apex domain. When the toggle is off, both the listing and the auto-verify lookup are restricted to the team's own domains.
Everything else stays independent. Apps belong to whatever team created them; the existing share-to-team flow still works for explicit copies.
Configurability
All sub-team behavior is driven by site config (admin → Settings → Teams & App Limits). Each value is also surfaced on the unauthenticated /api/site payload so SDK clients can mirror the gates in their UIs.
| Key | Type | Default | Effect |
|---|---|---|---|
enable_sub_teams | bool | true | Master switch — when false the sub-team endpoints reject every request and the UI hides the Sub-teams tab. |
max_team_depth | int | 5 | Server-enforced cap on nesting depth. Validated 1–20 on the admin API. |
inherit_team_membership | bool | true | Cascade member roles to descendants. |
inherit_team_domains | bool | true | Surface ancestor-owned domains on sub-team listings + auto-verify. |
default_team_profile_show_sub_teams | bool | true | Public-profile default for the sub-team listing section. |
The per-team override profile_show_sub_teams (column on teams) follows the same null/0/1 convention as every other profile_show_* flag: null follows the site default, 0/1 override.
Managing sub-teams
- Create:
POST /api/teams/:parentId/sub-teams(admin+ on the parent — direct or inherited counts) or the equivalent Teams → <team> → Sub-teams → New sub-team button. The creator becomes the new sub-team's owner. - List:
GET /api/teams/:id/sub-teamsreturns the immediate children with member counts and the caller's effective role. Members of an ancestor team can list. - Move / re-parent:
PATCH /api/teams/:id { "parent_team_id": "..." }withnullto promote back to top-level. Owner-only on the team being moved, and the caller must be admin+ on the new parent. The server walks both subtrees to reject moves that would create a cycle or exceedMAX_TEAM_DEPTH. - Delete:
DELETE /api/teams/:idcascades through every descendant. For each level (deepest first)dissolveTeamreassigns that level's apps to that level's own owner — sub-team owners keep their apps, with the deleting user as the final fallback. The DB schema hasparent_team_id REFERENCES teams(id) ON DELETE CASCADEas a belt to the application-level braces.
Inherited memberships in listings
GET /api/teams(session) andGET /api/oauth/me/teamsboth expand each direct membership to its full subtree. Entries carry aninherited_fromancestor id (ornullfor direct memberships).GET /api/teams/:idreturns:team.ancestors—[{id, name, avatar_url}]from immediate parent to root, useful for breadcrumbs.team.sub_teams— immediate children with member counts.team.my_role— the effective role;team.inherited_fromcarries the ancestor id when the role came from inheritance.members— direct members only (inherited members are visible by inspecting ancestor teams, surfacing them here would multiply listings).
Team-owned apps and domains
OAuth apps can be created directly under a team (Teams → <team> → Apps → New) or transferred in from a member's personal apps (Apps → <app> → Settings → Transfer). Personal apps that are transferred in are reassigned in-place — the client_id and client_secret remain valid, so partner integrations don't break.
Domains work the same way. A domain verified on a personal account can be shared with a team (POST /api/teams/:id/domains/:domainId/share-to-team) and later moved back (/share-to-personal), or fully transferred (/to-personal) to remove the team's edit access.
Team-as-user storage
Every team has a synthetic users row with kind = 'team' and id matching teams.id. This row exists only so oauth_apps.owner_id joins to a single table for both personal and team apps — it has no password, no sessions, no social connections, and cannot log in. The synthetic email and username (team-<id>@teams.invalid / team:<id>) are colon-prefixed to guarantee they can never collide with a real registration.
When a team is disbanded, dissolveTeam first reassigns any remaining team-owned apps to the team's owner (or, if there's no owner row, to the deleting admin). This survives the cascading delete on oauth_apps.owner_id.
Public team profiles
Like users, teams are private by default. The team owner (or admin) explicitly opts the team in at Teams → <team> → Settings → Public profile, then picks which sections to expose. Site-wide defaults and the master enable_public_profiles kill switch live in Configuration. See Public Profiles for the full per-section breakdown.
A team's public page links to the owner's /u/<username> page only when both profiles are public. If the owner's profile is private, the team page shows the display name without a link.
OAuth scopes
Three scope families touch teams, with very different blast radius. The full table with consent rules and worked examples is in OAuth → Team scopes — three tiers; the short version:
Aggregate teams:*
Acts on every team the user is a member of at once. One consent covers all of them. Endpoints under /api/oauth/me/teams[/...].
| Scope | Grants |
|---|---|
teams:read | List team memberships and roles |
teams:create | Create a new team |
teams:write | Update team settings; add and remove members across the user's teams |
teams:delete | Delete a team (owner only — checked at request time) |
Right shape for: "what teams is this user in?" use cases — workspace pickers, syncing membership lists, OIDC IdP claims for Cloudflare Access policies.
Single-team team:*
Acts on exactly one team, picked by the user at consent time. Prism rewrites team:read → team:<team-id>:read (via bindTeamScopes()) before issuing the token, so the token can only ever touch that team. Endpoints under /api/oauth/me/team/:teamId/....
| Requested | Bound form | Grants |
|---|---|---|
team:read | team:<id>:read | Read the team's settings |
team:write | team:<id>:write | Update the team's settings |
team:delete | team:<id>:delete | Disband the team |
team:member:read | team:<id>:member:read | List members and their roles |
team:member:write | team:<id>:member:write | Add/remove members and change roles |
team:member:profile:read | team:<id>:member:profile:read | Read a member's profile through the team scope |
Two extra rules at consent time (see worker/routes/oauth.ts:830-859):
- The user must be
owner,co-owner, oradminof the chosen team. team:deleteadditionally requiresownerorco-owner— admins can grant reads/writes but only the people who could actually disband the team can grant deletion.
team:member:write also can't escalate beyond what the granting user could do themselves: an admin granting it cannot give the app the ability to promote past admin — the cap is enforced on every member mutation.
Each grant is audited in team_scope_grants (team id + permissions), independent of the OAuth consent record.
Right shape for: an integration scoped to a single team — a deploy bot for one workspace, a chatbot for one team's channel, etc.
Cross-instance site:team:*
Cross-team admin access without a per-team consent. Granting requires the user to be a site admin and goes through the site-scope confirmation flow (2FA + the exact phrase grant site access). Use only for site-administration tools.
oidc_fields claim
The same oidc_fields mechanism that surfaces user role in ID tokens also emits per-team claims when an app declares them — useful for Cloudflare Access policies that depend on team membership. The teams:read scope unlocks the flat teams claim plus the in_team_<id> / role_in_team_<id> per-team markers. See the Cloudflare Access integration for details.
Picking a tier
- Use
teams:*when the integration cares about the user's whole team graph (membership sync, claim mapping). - Use
team:*when the integration scopes to one workspace at a time — the smaller blast radius is worth the team-id picker on the consent screen. - Don't request
teams:*andteam:*together: you'll get the union, but the consent UX shows both an all-teams notice and a team-id picker on the same screen, which confuses users. - Reserve
site:team:*for site-administration tooling — anything granted there bypasses individual team owners' consent.
Endpoint summary
See API → Teams for the full table. The most-used endpoints:
GET /api/teams list memberships (expanded with inherited)
POST /api/teams create (optionally with parent_team_id)
PATCH /api/teams/:id update settings, requirements, parent_team_id
GET /api/teams/:id team + ancestors + sub_teams summary
GET /api/teams/:id/sub-teams list immediate children
POST /api/teams/:id/sub-teams create a sub-team under :id
GET /api/teams/:id/members list members (direct only)
POST /api/teams/:id/members add by username/id
PATCH /api/teams/:id/members/:userId change role
DELETE /api/teams/:id/members/:userId remove (or leave with self)
POST /api/teams/:id/transfer-ownership transfer to a co-owner (direct owner only)
GET /api/teams/join/:token preview an invite (auth optional)
POST /api/teams/join/:token accept