Teams
Most software people pay for is used by more than one person at a time: a workspace, an
account, a tenant. Building that yourself means a teams table, a membership table, an
invite flow, and — the part that goes wrong — a teamId on every record and a filter on
every query. Miss one filter and one customer reads another’s data.
Teams removes that job. Turn it on, declare a collection with "scope": "team", and the
records inside it belong to the team the caller is in. There is no team id to pass and
none to store.
Teams requires Sign-in, and turns it on with you if it isn’t already — a team needs a signed-in person to belong to it.
{ "collections": { "projects": { "scope": "team", "fields": { "title": "string", "status": "string" } } }}import { collection } from "data";
const projects = collection("projects");
await projects.list(); // this team's projectsawait projects.create({ title: "…" }); // adds one to this teamTwo people in different teams run that exact code and never see each other’s rows. The partition is decided by the platform from the session, so a query cannot forget it.
The team screen is an import
Section titled “The team screen is an import”The app never hand-writes a request to the team endpoints. It imports them:
import { currentTeam, teamMembers, isTeamOwner, inviteMember, TeamError } from "team";
const team = await currentTeam(); // {id, name, role} — or null when signed outconst members = await teamMembers(); // [{id, email, name, role, joinedAt}]
if (await isTeamOwner()) { await inviteMember("kim@acme.com"); // or inviteMember(email, "owner")}Each call answers the thing itself. The wire wraps a team in {"team": …} and an invite
in {"invite": …}; the import unwraps it, so it is team.name, never result.team.name.
currentTeam() |
the active team, or null when signed out |
myTeams() |
every team this user is in |
teamMembers() |
who is in the active team |
teamInvites() |
invites this team has sent and nobody has accepted yet |
pendingInvites() |
invites waiting for this user |
isTeamOwner() |
whether they own the active team |
refreshTeam() |
re-read all of the above, and answer the active team |
createTeam(name) |
a new team they own, and it becomes the active one |
renameTeam(name) |
rename the active team |
switchTeam(team) |
make another of their teams the active one |
acceptInvite(team) |
join a team that invited them, and switch to it |
inviteMember(email, role?) |
invite an address |
revokeInvite(email) |
take a sent invite back |
setMemberRole(id, role) |
promote or demote somebody |
removeMember(id) |
an owner removes somebody |
leaveTeam() |
the caller leaves |
The six reads share one request — a whole team screen can call every one of them. Each
write drops that cached answer, so the next read is fresh; refreshTeam() re-reads now and
is what a screen repaints from after a change.
The calls that name a team, a member or an invite take the object you just rendered as
happily as its id, so acceptInvite(entry) works straight off an entry from
pendingInvites().
A call that fails throws a TeamError carrying .status and the server’s own message —
403 you are not an owner, 409 a limit or the last-owner rule, 429 slow down. Catch it
and show the message.
Nobody creates a team first
Section titled “Nobody creates a team first”A user who belongs to no team gets one of their own the moment they sign in, named after them, and they own it. So a team collection works for a single user on day one, and the same code keeps working when they invite four colleagues — which is the point.
This mirrors how the rest of the platform behaves: a scope always means something, even before the feature that splits it is used.
Roles inside a team
Section titled “Roles inside a team”A member of a team is an owner or a member. That is separate from the account role
(member or staff) that decides who runs the whole app — a
person can own one team and be a plain member of another.
| owner | member | |
|---|---|---|
| Read and write the team’s records | yes | yes |
| Rename the team | yes | no |
| Invite someone, or revoke an invite | yes | no |
| Change a member’s role | yes | no |
| Remove a member | yes | no |
| Leave | yes, if another owner remains | yes |
A team always keeps at least one owner: the last one cannot leave or be demoted. To hand a
team over, promote someone else first — setMemberRole(them, "owner"), then leaveTeam().
Gate the owner-only controls on await isTeamOwner() rather than letting somebody press a
button that can only answer 403.
An invite is an address, not a link
Section titled “An invite is an address, not a link”inviteMember("kim@acme.com") creates no account and mails no token to paste. The next time
Kim signs in — with that address — the invitation is waiting in pendingInvites(), and
acceptInvite(it) joins them.
That is deliberate. A forwarded invite link is a security hole in most products; here the only person who can accept is the one who proved they own the address at sign-up. An invite lasts 14 days, and inviting the same address again just refreshes it.
If Email is on, the invited person also gets a note telling them to sign in. If it isn’t, the invite still works — they just won’t hear about it from your app.
The wire underneath
Section titled “The wire underneath”The team import calls these, and they are the whole surface. The app has no reason to
name one — but server.mjs is a lone module with no module graph, so it cannot import
anything; there, reach the same routes through env.PLATFORM.fetch(), which takes the same
paths and carries the caller’s session.
| Method & path | Body | Returns |
|---|---|---|
GET /api/_team |
— | {"team","teams","members","invites","pending"} — the whole team screen in one call |
POST /api/_team |
{"name":"Acme"} |
201 {"team"} — creates it, you own it, and it becomes the active one |
PATCH /api/_team |
{"name":"Acme Inc"} |
{"team"} — renames the active team (owner only) |
POST /api/_team/switch |
{"team":"team_..."} |
{"team"} — makes another of your teams the active one |
POST /api/_team/invites |
{"email":"kim@acme.com","role?":"member"} |
201 {"invite"} — owner only; emails them if this app can send |
DELETE /api/_team/invites/:email |
— | {"revoked":true} — owner only |
POST /api/_team/accept |
{"team":"team_..."} |
{"team"} — joins a team that invited you, and switches to it |
PATCH /api/_team/members/:id |
{"role":"owner"} |
{"member"} — owner only; this is how ownership is handed over |
DELETE /api/_team/members/:id |
— | {"removed":true} — an owner removes anyone; anyone removes themselves |
Every one of them needs a signed-in caller. GET /api/me carries the active team too, so
currentUser() already knows it:
{ "id": "usr_…", "email": "kim@acme.com", "name": "Kim", "role": "member", "team": { "id": "team_…", "name": "Acme", "role": "owner" } }That inner role is their standing in the team. currentUser().role is the account role
that decides who runs the whole app; currentTeam().role is this one.
Switching
Section titled “Switching”The active team is remembered on the server, per user — not in the tab. It follows
someone to their phone and survives a sign-out, and every read and write of every team
collection follows it. So a team switcher has to repaint: await switchTeam(other), then
re-read the records, and never leave the previous team’s rows on screen.
What staff see
Section titled “What staff see”A staff account reading a team collection gets every team’s
records, the same widening they get on a user collection. Each record carries its owner
— the team it belongs to — so a back office can say whose it is. That is what makes support
tooling possible, and it is why a staff screen should never be the screen an ordinary
member uses.
Files, too
Section titled “Files, too”A file key beginning team/ lands in the same tenancy: every member of the team reads and
writes it, and no other team can. It sits alongside the public/ and shared/ prefixes
Files already understands.
Limits
Section titled “Limits”| Members per team | 200 (invites count against it) |
| Teams one person can be in | 20 |
| Invite lifetime | 14 days |
Calls to /api/_team |
30/min per caller |