Teams (teams)
Organizations for your SaaS: people create teams, invite others by email, give them roles, and the
data of a team stays inside it. Mark a resource team: true and every route and service call keeps
its records to the members of their team: lists skip other teams, foreign ids answer 404, records
cannot be created in (or moved to) a team you are not in. New policy names, 'member' and
'team:admin', work anywhere a policy does: resources, custom routes, visibleTo(), writableBy().
npx calm add teamsAdds#
| Files | app/blocks/teams/index.js (scoping, policies, routes, doctor), app/blocks/teams/teams.js (memberships, invitations and their rules), app/blocks/teams/roles.js, app/resources/team/team.resource.js |
| Environment | Nothing new. Invitation links are built from APP_URL. |
| Collections | teams (the team resource), calm_team_memberships, calm_team_invitations (finished invitations are removed by a TTL index after 30 days) |
| Policies | member, team:owner, team:admin, team:member, plus team:<role> for each role you add |
| Header | X-Team: <teamId>, the active team (optional) |
Team-scoped resources#
Add team: true. The block adds a team field (f.ref('team'), filterable, indexed, immutable):
import { resource, f } from '#calm';
export default resource('project', {
team: true,
fields: {
name: f.string().required().max(100).sortable(),
budget: f.number().visibleTo('team:admin'), // admins and owners of the record's team
author: f.ref('user').owner(),
},
policy: {
list: 'member',
read: 'member',
create: 'member',
update: 'team:admin | owner', // team admins, or the author
delete: 'team:admin',
},
});What you get, whatever the policies say:
GET /api/projects | records of the teams you belong to. Narrow it with ?team=<id> or X-Team: <id> (403 if you are not in that team). With a list policy made only of team rules ('team:admin'), only teams where you have that role. |
GET /api/projects/:id | 404 for a record of another team, guessed id or not |
POST /api/projects | { "name": "Apollo", "team": "<teamId>" }, or the X-Team header instead of team. 422 without a team, 403 for a team you are not in |
PATCH /api/projects/:id | team cannot change (422), from the API or from service.update() in your code |
| Global admins | not members either: they see no team's records through the API. Use { system: true } in your own admin tooling, with an explicit filter |
You may declare the field yourself (team: f.ref('team').populate()); it must be f.ref('team')
and named team. The block makes it immutable and not required for validation (the header can
supply it), and requires it on create.
Policies#
| Policy | Allows |
|---|---|
'member' | anyone in the team, whatever their role |
'team:owner' | owners |
'team:admin' | admins and owners |
'team:<role>' | that role or any role above it (roles are ordered) |
Combine them as usual: 'team:admin | owner', 'member | admin'. Which team a rule is about:
- the record's team, when there is a record (a header or a body can never redirect this);
:teamIdin the path, for your own routes;- on create,
teamin the body, else theX-Teamheader; - the
X-Teamheader, else (lists)?team=.
export default (r) => {
r.get('/teams/:teamId/report', { policy: 'team:admin', summary: 'Monthly report' }, async (ctx) => {
return ctx.services.project.find({ team: ctx.params.teamId }, ctx);
});
// With X-Team, ctx.team is { id, role } (checked against the caller's memberships).
r.get('/dashboard', { policy: 'member' }, (ctx) => ({ team: ctx.team }));
};In your own code, (await app.ext.teams.memberships(ctx.user)).get(teamId) is the caller's role in
a team (or undefined).
Routes#
Every route is under /api/teams. Teams are returned as { id, name, slug, role, createdAt, updatedAt },
role being yours.
| Route | Policy | Body | Answer |
|---|---|---|---|
POST /teams | createPolicy (signed in) | { name, slug? } | 201 team, you are its owner |
GET /teams | signed in | your teams, with your role | |
GET /teams/:teamId | member | the team | |
PATCH /teams/:teamId | team:admin | { name?, slug? } | the team |
DELETE /teams/:teamId | team:owner | 204; memberships, invitations and (deleteRecords) its records go too | |
GET /teams/:teamId/members | member | [{ user: { id, name, email }, role, joinedAt }] | |
PATCH /teams/:teamId/members/:userId | team:admin | { role } | { userId, role } |
DELETE /teams/:teamId/members/:userId | team:admin | 204 | |
POST /teams/:teamId/leave | member | 204 | |
POST /teams/:teamId/transfer | team:owner | { userId } | { owner, previousOwner: { userId, role: 'admin' } } |
POST /teams/:teamId/invitations | team:<inviteRole> | { email, role? } | 201 invitation |
GET /teams/:teamId/invitations | team:<inviteRole> | open invitations (pending or expired) | |
DELETE /teams/:teamId/invitations/:invitationId | team:<inviteRole> | 204, the link stops working | |
POST /teams/:teamId/invitations/:invitationId/resend | team:<inviteRole> | the invitation, with a new link and expiry (429 during the cooldown) | |
POST /teams/invitations/lookup | public | { token } | { team: { id, name, slug }, email, role, invitedBy: { name }, expiresAt } |
POST /teams/invitations/accept | signed in | { token } | the team you joined, with your role |
GET /teams/invitations | signed in, verifiedEmailField only | invitations to your verified address | |
POST /teams/invitations/:invitationId/accept | signed in, verifiedEmailField only | the team you joined |
An invitation is { id, team, email, role, status, invitedBy: { id, name }, expiresAt, lastSentAt, createdAt }.
Errors use the usual shape; the codes worth handling: LAST_OWNER (409), ALREADY_MEMBER (409),
ALREADY_INVITED (409, resend it instead), TEAM_LIMIT and INVITATION_LIMIT (409), INVALID_INVITATION (400),
INVITATION_EMAIL (403: signed in with another address).
Invitations#
POST /api/teams/66f0.../invitations
{ "email": "grace@example.com", "role": "admin" }The email links to APP_URL/invite?token=invite_... (option linkUrl). That page:
- shows what the link is about:
POST /api/teams/invitations/lookup { token }; - makes the visitor sign in or sign up with the invited address;
- calls
POST /api/teams/invitations/accept { token }.
Links expire after 7 days (invitationTtlDays) and work once. Accepting with another account answers
403 INVITATION_EMAIL and does not use the link up. Inviting an address that already has an open
invitation answers 409 ALREADY_INVITED: resend it (a new link, the old one stops working). An expired
invitation no longer blocks a new one.
Roles and rules#
| Owner | Admin | Member | Your roles | |
|---|---|---|---|---|
See the team, its members, its records (member) | yes | yes | yes | yes |
| Rename the team, change its slug | yes | yes | ||
Invite (default inviteRole: 'admin') | yes | yes | if inviteRole allows | if inviteRole allows |
| Give a role | any | up to admin | up to their own | up to their own |
| Change the role of, or remove, an admin or member | yes | yes | ||
| Make or unmake owners, remove owners | yes | |||
| Transfer ownership, delete the team | yes | |||
| Leave | unless last owner | yes | yes | yes |
- Nobody acts on someone above them (an admin cannot touch an owner).
- A team always has an owner: the last one cannot be demoted, removed, or leave. Make someone else owner first, transfer, or delete the team. Two owners demoting each other at the same moment are caught after the write and one change is undone.
- Transfer makes a member the owner and you an admin.
- When an account is deleted (
DELETE /api/users/:id, or the privacy block), it leaves its teams. A team that loses its last owner gets the longest-standing admin (else member) as owner; a team left with nobody is deleted.
Custom roles go between the built-in ones, ordered from most to least powerful:
export default defineConfig({
blocks: {
teams: { roles: ['owner', 'admin', 'billing', 'member', 'viewer'], defaultRole: 'member' },
},
});'team:billing' then allows billing, admins and owners; 'member' allows everyone including viewers.
Options#
calm.config.js, blocks: { teams: { ... } }:
| Option | Default | |
|---|---|---|
roles | ['owner', 'admin', 'member'] | ordered, starts with owner and admin |
defaultRole | 'member' | role of an invitation that does not name one |
createPolicy | 'authenticated' | who may create teams ('admin', a plan check...) |
inviteRole | 'admin' | lowest role that may invite and manage invitations ('member': everyone) |
invitationTtlDays | 7 | |
resendCooldownSeconds | 60 | |
maxOpenInvitations | 100 | open invitations per team (409 INVITATION_LIMIT beyond), 0 for no limit |
linkUrl | APP_URL + '/invite' | page of your frontend that accepts invitations |
verifiedEmailField | null | a user field set once the email is verified (e.g. 'emailVerifiedAt'): verified users see and accept their invitations without the link |
showMemberEmails | true | members see each other's addresses |
maxOwnedTeams | 20 | teams one person may own, 0 for no limit |
deleteRecords | true | deleting a team deletes its records in team-scoped resources |
header | 'x-team' | the active team header |
Scripts and seeds: app.ext.teams.create(user, { name }), addMember(teamId, userId, role),
removeMember(teamId, userId), delete(teamId), memberships(user), roleOf(teamId, userId).
Events#
| Event | Payload |
|---|---|
team.created | { team: { id, name, slug }, userId } |
team.updated | { team, by, changes } |
team.deleted | { team, by, records: { project: 12 } } |
team.member_added | { team, userId, role, by, via: 'invitation' | 'script' } |
team.member_removed | { team, userId, role, by, reason: 'removed' | 'left' | 'account_deleted' | 'script' } |
team.role_changed | { team, userId, from, to, by, via? } |
team.invitation_sent | { team, invitationId, email, role, by, resend } |
team.invitation_accepted, team.invitation_revoked | { team, invitationId, ... } |
security.invalid_team_invitation | { reason, userId, ip, invitationId?, team? }, reason unknown, wrong_email, used, expired, revoked, team_deleted |
Security notes#
- Isolation is not a policy. It lives in the service
scope()of each team-scoped resource, which every query goes through (CRUD, custom routes using the service, realtime, webhooks, MCP). A policy mistake ('authenticated'instead of'member') cannot expose another team's records. - Memberships are read for every request, once, never cached across requests: removing a member
takes effect on their next call, including live streams (
app.canRead()reads them fresh). - The team named by a client is always checked against the caller's memberships: body, header, path or query. With a record in hand, only the record's own team counts.
- Records never move between teams (immutable field, and refused in
service.update()too). - Invitation tokens are 256 random bits, stored as SHA-256 hashes, expire, and are claimed with one atomic update. They are bound to the invited address: a forwarded or leaked link is useless to another account, and a wrong account does not burn it. Accepting is rate limited like sign-in.
- No email verification is assumed. CalmAPI does not verify addresses, so anybody could sign up
with someone else's address; that is why accepting needs the token unless you set
verifiedEmailFieldto a field that your own verification flow sets. - Unknown and foreign teams look the same (403), so team ids cannot be probed.
- Global
admingets no access to team data through the API. Support tooling should use{ system: true }service calls with an explicitteamfilter, behind its own policy. - In your own service code, a call for another user than the request's (
service.list(q, { user: someoneElse })) is refused with an error, not run unscoped: useapp.canRead()or{ system: true }. calm auditand the exposure matrix show team rules ascustom(a membership is data they cannot invent) and skip those checks: cover team-scoped resources with tests (two teams, a non-member, a removed member).calm doctorlists team-scoped resources and warns about resources that reference a team without being team-scoped.
Frontend: a team switcher#
// Keep the active team in the client and send it with every request.
let activeTeam = localStorage.getItem('team');
async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(`/api${path}`, {
method,
headers: {
authorization: `Bearer ${session.token}`,
...(activeTeam ? { 'x-team': activeTeam } : {}),
...(body ? { 'content-type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 403 && activeTeam) {
// Removed from the team meanwhile: forget it and let the user pick another one.
localStorage.removeItem('team');
activeTeam = null;
}
return res.status === 204 ? null : (await res.json());
}
async function renderSwitcher(select) {
const { data: teams } = await api('/teams');
select.innerHTML = teams.map((t) => `<option value="${t.id}">${t.name} (${t.role})</option>`).join('');
if (!teams.some((t) => t.id === activeTeam)) activeTeam = teams[0]?.id ?? null;
select.value = activeTeam ?? '';
select.onchange = () => {
activeTeam = select.value;
localStorage.setItem('team', activeTeam);
location.reload(); // or refetch your views
};
}
// The invitation page (APP_URL/invite?token=...)
const token = new URLSearchParams(location.search).get('token');
const { data: invite } = await (await fetch('/api/teams/invitations/lookup', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token }),
})).json();
// "Join invite.team.name as invite.role": sign in or sign up as invite.email, then:
await api('/teams/invitations/accept', { method: 'POST', body: { token } });Official documentation#
Read these before changing the block; they are also in app/blocks/teams/AGENTS.md and in the devtools Blocks panel.
- OWASP Authorization Cheat Sheet
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet
- OWASP API Security Top 10: Broken Object Level Authorization
- MongoDB: Model One-to-Many Relationships with Document References
- MongoDB: Compound Indexes
- CalmAPI: Building blocks
For AI agents#
Coding agents read these notes in app/blocks/teams/AGENTS.md:
This project uses the teams block (app/blocks/teams/). Read app/blocks/teams/index.js (scoping, policies, routes) and teams.js (membership and invitation rules) before changing anything about tenancy.
Rules that must never break:
- Tenant isolation: a record of a team-scoped resource (team: true) is visible only to members of its team. Never bypass the service scope() for user-facing reads; { system: true } is for jobs and admin tooling only, with an explicit team filter.
- Never trust a team id from the body, query or X-Team header without checking membership. Use the policies ('member', 'team:admin', 'team:owner') or
(await app.ext.teams.memberships(ctx.user)).get(teamId). - The team field is immutable: records never move between teams. Do not add a route that rewrites it.
- A team always has an owner: never demote or delete the last owner membership directly in MongoDB; use the block functions (changeRole, removeMember, transfer).
- Invitation tokens are stored as SHA-256 hashes and are single use. Never log, return or store a raw token.
Common tasks:
- Make a resource team-scoped: add
team: trueto resource(...) and use policies like list: 'member', create: 'member', update: 'team:admin'. Clients sendteamin the create body or an X-Team header. - Team routes of your own: put :teamId in the path and use { policy: 'team:admin' }; the policy reads req.params.teamId.
- Seeds and scripts: app.ext.teams.create(user, { name }), app.ext.teams.addMember(teamId, userId, 'admin').
- New role: add it to blocks.teams.roles in calm.config.js (ordered, after 'owner' and 'admin'), then use 'team:<role>'.
Pitfalls: scope() needs the caller's memberships loaded by the block's guard, so service calls with another user than the request's must go through app.canRead() or { system: true }. The exposure matrix shows team rules as custom: write tests (two teams, a non-member, a removed member) for every team-scoped resource.
<!-- block-guide:end -->