20. Teams as organizations
Let people create teams, invite each other with a link or an email, keep a team's pins to its members, give roles inside a team, limit a team's pins by plan with a claim, and delete a team with its report first.
Until now Shelf was one workspace. Real groups want their own corner: this team’s pins, readable by this team and nobody else, with a person who runs it and people who only look. That is what an organization is: a team is an organization of the realm, created by whoever starts it, and the rules read who belongs to which.
The old way to do this is an array of member ids on every document (or a members collection to keep in sync). It works until someone leaves: their id stays in the arrays until something removes it, and every rule and every query has to know about the array. An organization has none of that: access ends the moment someone leaves, because there is nothing to keep in sync.
Declare the roles
A team’s roles are not the realm’s roles (admin, curator): they mean something only inside one team. realm.yaml declares them, with what each may manage:
Add to config/shelf/realm.yaml:
organizations:
roles:
owner: { description: Runs the team, manage: [org, members, roles, teams] }
admin: { description: Invites and removes people and deletes any pin, manage: [members, teams] }
editor: { description: Adds and edits pins }
member: { description: Reads the team's pins }
default_roles: [member]
max_created_per_user: 5
invitation_link: http://localhost:8080/?join={token}
ownerruns the team: rename it, its members, which roles they hold, and delete it.admininvites and removes people (and, in the rules below, deletes any pin), but can’t give or takeowner, or remove an owner.editorandmembermanage nothing;memberis held by everyone without being granted (default_roles).invitation_linkis where an invitation’s link points: the app, which reads?join=and offers it.
A collection that belongs to teams
Pins are what a team shares. The collection says every pin belongs to a team and what happens to them when the team is deleted:
examples/config/shelf/main/pins/collection.yaml
# Chapter 20: the team's pinboard. Each pin belongs to one team, which is an
# organization of the realm: backd keeps it in _meta.org, the caller says
# which with ?org= (`pins.inOrg(id)` in the client), and the rules read
# the caller's memberships.
organization: required
# When a team is deleted, its pins go with it (the other choice is `keep`).
on_org_delete:
action: delete
rules:
# A member of the team reads its pins; anybody else, nothing. This is applied
# inside the database query, so lists and counts show only what is readable.
read: inOrg(document._meta.org)
# No create rule: a pin is made by the pin function (chapter 20), which
# knows the team's plan and how many pins fit. Clients can't create one.
# Editing and removing: the person who pinned it, and the team's admins.
update: >
user != nil && document.author == user.id
&& (hasOrgRole(document._meta.org, 'editor') || hasOrgRole(document._meta.org, 'admin') || hasOrgRole(document._meta.org, 'owner'))
delete: >
user != nil && (document.author == user.id
|| hasOrgRole(document._meta.org, 'admin') || hasOrgRole(document._meta.org, 'owner'))
# What happens to what a member pinned when they are erased: the pins stay
# with their team (the content is the team's), and only the personal link,
# `author`, is cleared.
on_owner_delete:
unset:
author: id
# Chapter 22: the team's dashboard counts pins per member per month. Only the
# fields named here can be grouped by, so what a summary costs is decided in
# this file; the index it needs is in indexes.json.
aggregate:
group_by: [author, _meta.created_at]_meta.org holds the team. The schema has no team field of its own: backd sets it at creation from ?org=, and nobody can change it afterwards. The read rule is a database filter: a list shows a member only the pins of the teams they belong to, and counts and pages follow.
There is no create rule: clients can’t make pins. The pin function does, and that is how the plan’s limit is enforced.
The plan, and the function that counts
Rules can’t count, so a limit lives in a function. A team’s claims are facts about it that only realm administrators and functions write; plan is one. The pin function reads it from ctx.user.orgs, counts the team’s pins with the admin client, and refuses past the limit:
examples/config/shelf/main/_functions/pin/index.ts
// pin: add a pin to a team's board (chapter 20).
import { defineFunction } from "@backd/functions";
/** How many pins a team's plan allows. A team without a `plan` claim is on the free one. */
const LIMITS: Record<string, number> = { free: 5, pro: 100 };
const WRITERS = ["owner", "admin", "editor"];
export default defineFunction("main/pin", async (ctx) => {
const { org, title, url, note } = ctx.input;
// ctx.user.orgs is what the caller is in every team they belong to: their
// effective roles there, the team's claims, and whether the membership
// counts for this session (a team that requires a second factor).
const team = ctx.user?.orgs?.[org];
if (!team?.counts) throw ctx.error(403, "not_org_member", "You are not a member of that team");
if (!team.roles.some((r) => WRITERS.includes(r))) {
throw ctx.error(403, "forbidden", "Only editors, admins and owners pin");
}
const plan = typeof team.claims.plan === "string" ? team.claims.plan : "free";
const limit = LIMITS[plan] ?? LIMITS.free;
const pins = ctx.admin.db("main").collection("pins");
const { total = 0 } = await pins.list({ where: { "_meta.org": org }, limit: 1, count: true });
if (total >= limit) {
throw ctx.error(409, "plan_limit", `The ${plan} plan holds ${limit} pins`);
}
const pin = await pins.create({ title, url, note, author: ctx.user!.id }, { org });
return { id: pin.id, pins: total + 1, limit };
});An operator puts a team on a plan with another function, the only place the claim is written:
examples/config/shelf/main/_functions/team-plan/index.ts
// team-plan: set a team's plan and time zone, which the pin function (chapter 20) and the
// dashboard (chapter 22) read. Both are claims of the team's organization.
import { defineFunction } from "@backd/functions";
export default defineFunction("main/team-plan", async (ctx) => {
const { org, plan, timezone } = ctx.input;
if (plan === undefined && timezone === undefined) {
throw ctx.error(400, "nothing_to_set", "Give a plan, a timezone or both");
}
if (timezone !== undefined) {
try {
new Intl.DateTimeFormat("en", { timeZone: timezone });
} catch {
throw ctx.error(400, "invalid_timezone", "Not an IANA time zone, like Europe/Madrid");
}
}
try {
// setClaims replaces them all: start from what the team has.
const current = await ctx.admin.orgs.get(org);
const claims = { ...current.claims };
if (plan !== undefined) claims.plan = plan;
if (timezone !== undefined) claims.timezone = timezone;
await ctx.admin.orgs.setClaims(org, claims);
return { org, plan: claims.plan as string | undefined, timezone: claims.timezone as string | undefined };
} catch (err) {
// An unknown team is the caller's mistake, not a failure of ours.
if ((err as { status?: number }).status === 404) throw ctx.error(404, "not_found", "No such team");
throw err;
}
});The app
The Teams view creates a team, shows the open team’s pins and members, and invites. A pin goes through the function; reading uses the collection scoped to the team:
const team = await backd.orgs.create({ name: 'Docs' }) // the caller is the owner
await main.fn('pin', { org: team.id, title: 'Handbook', url }) // the plan decides
const { items } = await pins.inOrg(team.id).list() // ?org= is a filter
An invitation answers its link once, as a link to copy, and can also be emailed:
const invite = await backd.orgs.invitations(team.id).create({ binding: 'email', email, roles: ['editor'], send: true })
invite.link // http://localhost:8080/?join=bdo_…
The person who opens it signs in, sees what it offers (invitations.preview) and takes it (invitations.accept). Anybody who holds a link can use it, so an open link never gives a role that manages the team.
Roles directly, access through teams
Inside a team, teams are groups of members that carry roles. Give management roles (owner, admin) directly, and access roles through a team:
const editors = await backd.orgs.teams(team.id).create({ name: 'Editors', roles: ['editor'] })
await backd.orgs.teams(team.id).addMember(editors.id, userId)
A member who holds editor both ways keeps it after being taken out of the team: removal looks done and isn’t. The members list shows where each role comes from, and marks a role held twice.
A second factor, the last owner, deleting
require_mfa. An owner can require a second factor of the team’s members (orgs.update(id, { require_mfa: true }), with a fresh proof, from a session that has one). A session without it stops counting as a member there; the app asks for one (mfa_metis false).- The last owner stays.
leaveand any change that would leave nobody who can manage the team answer409 last_org_manager. Make someone else an owner first. - Deleting a team. An owner deletes it with a fresh proof; an operator looks first at what it holds:
backd orgs owned --realm shelf --id <id>. Access ends at once and a job removes its pins (on_org_delete: delete).
You should see
- Creating a team makes you its owner; another member joins with the link and reads the team’s pins; a stranger can’t read the team or pin into it (
not_org_member). - An editor pins and removes their own pins; only an admin removes other people’s.
- The sixth pin of a free team is refused (
plan_limit); afterteam-planputs it onpro, it fits. - The only owner can’t leave (
last_org_manager); with another owner, leaving ends access at once. - Deleting the team removes its pins, and
backd orgs ownedcounted them first.
Read Organizations for the API, Organizations in functions for ctx.user.orgs and ctx.admin.orgs, and the rules for inOrg and hasOrgRole.