Skip to content

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}
  • owner runs the team: rename it, its members, which roles they hold, and delete it.
  • admin invites and removes people (and, in the rules below, deletes any pin), but can’t give or take owner, or remove an owner.
  • editor and member manage nothing; member is held by everyone without being granted (default_roles).
  • invitation_link is 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_met is false).
  • The last owner stays. leave and any change that would leave nobody who can manage the team answer 409 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); after team-plan puts it on pro, 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 owned counted 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.

Edit this page · Last updated 15 Oct 2026