Roles and powers

Who may do what is declared once, by you, and read by every route. This page is what the kit owns, what you own, what the database holds, and which of those three you can still change after somebody has joined.

Three places, and they move at different speeds

The kit owns seven power names. Tenancy’s own routes gate on five of them. Media and storage gate on the other two. All seven are closed, and a release is what changes them.

You declare your own powers and the whole matrix, with defineRoles in src/organization/roles.ts. That is a deploy.

D1 holds the instances. A membership’s role column is a string from your declared set — text, because the catalog is yours and is not known when the schema compiles. Those are rows people are standing on.

The seven the kit reserves

PowerWhat the kit gates on it
organization:readReading the organization, its roster, and a member’s stored image
organization:manageRenaming it, setting its mark, changing a role, removing somebody, and every invitation route
organization:deleteEnding the account, and everything belonging to it
members:manageHanding somebody a role that administers — by invitation or by a role change
billing:manageYour product’s billing relationship. Not the ownership routes, which are gated on holding the account instead
media:manageEditing, deleting and changing the visibility of any media record in the organization
storage:manageThe same, for any stored file

Reserved, not merely taken. Declaring one of these in your own powers is refused, naming it. A second definition of a power the capability’s own handlers gate on would be two definitions kept in step by hand.

Each has to be held by some role, where something gates on it. A catalog in which no role holds organization:delete describes an account nobody can ever end, so it is refused at boot. Tenancy’s five are required always. media:manage and storage:manage are required only in a Worker that composes media or storage.

members:manage is narrower than its name. Running the roster — inviting a reader, withdrawing an offer, removing somebody, demoting an administrator — is organization:manage. What members:manage gates is minting another administrator. An administrator who could mint a second one could install a fourth party at their own level, and the two of them could hand the account on between themselves.

Two shapes of catalog, and neither is the default

A catalog that nests, the dashboard’s own:

import { defineRoles } from "@pithy-sh/organization/src/roles/roles";

export const roles = defineRoles({
  powers: ["connections:read", "connections:manage"],
  roles: {
    member: ["organization:read", "connections:read"],
    admin: ["organization:read", "connections:read", "connections:manage", "organization:manage"],
    owner: [
      "organization:read",
      "connections:read",
      "connections:manage",
      "organization:manage",
      "members:manage",
      "billing:manage",
      "organization:delete",
    ],
  },
  administrativePower: "organization:manage",
  nests: ["member", "admin", "owner"],
  unassignable: ["owner"],
});

A catalog that does not, a coaching academy’s. coach and student are siblings, and each holds what the other does not:

export const roles = defineRoles({
  powers: ["sessions:read", "sessions:accept", "sessions:request", "students:block", "coaches:block"],
  roles: {
    owner: ["organization:read", "organization:manage", "organization:delete", "members:manage", "billing:manage", "sessions:read"],
    admin: ["organization:read", "organization:manage", "members:manage", "sessions:read"],
    coach: ["organization:read", "sessions:read", "sessions:accept", "students:block"],
    student: ["organization:read", "sessions:request", "coaches:block"],
  },
  administrativePower: "organization:manage",
});

The only thing the two share is tenancy’s five. A kit that assumed nesting would refuse the academy outright.

nests is asserted, never assumed

nests lists roles weakest first, and defineRoles checks the claim: every power the weaker role holds, the stronger one must hold, or the catalog is refused naming both roles and every power that is missing. That is what makes a promotion and a demotion predictable.

It takes a chain, not every role. A catalog whose admin and owner nest while coach and student sit beside both declares ["admin", "owner"] and says nothing about the other two. Omitting nests entirely is not a weaker catalog. It is a different shape of one.

administrativePower is named, never inferred from a role spelled admin

It is required, it must be a power some role holds, and it is what the last-administrator rule counts over. The last holder of administrativePower cannot be demoted, removed or leave.

Counting a role name would get this wrong both ways. In the nesting catalog an owner holds organization:manage too, so an account with one owner and one admin is still administered when the admin goes. And a role added later that carries the power is governed by the rule the day it is declared.

Assignability is derived by exclusion

unassignable names the roles no member may hand another person, by a role change or by an invitation. Everything else is assignable, so a new role is assignable by default and excluding it is the deliberate act.

An unassignable role is also stuck on the member surface. Somebody holding one cannot be demoted, removed or leave through the member routes. In the dashboard catalog that is the whole of the ownership model: ownership is accepted rather than conferred, so it moves only by the two-party transfer, and a newly founded account has no owner. The founder gets the first role that administers and may be assigned.

An operator is bound differently. Through membership administration an operator may add a member at, or change a role to, any role the catalog declares, unassignable ones included — except the role your ownership pair confers, which moves only through the forced-ownership route.

Both doors to an administering role call one predicate. Offering a role in an invitation and promoting a member to it are the same act arriving by two routes, and requireMayAssign is the one function both call.

A role is decoded, not asserted

Every read parses the stored value through the catalog before anything is decided. A repair script, a rolled-back deploy or a bug can put anything in a text column, and a role matching no entry in the matrix would deny everything today and, one refactor later, allow it. An unrecognized value refuses while there is still a request to refuse, and the junk value goes in detail for whoever reads the log.

What defineRoles refuses

All of it where the constant is written, because each is a fact about the declaration. Every refusal is organization/invalid_role_catalog.

RefusedBecause
A declared power colliding with one of the kit’s sevenTwo definitions of a power the capability’s handlers gate on
A power declared twiceOne name, one meaning
No roles at allA membership has to hold something
A role holding a power nobody declaredA typo is a role quietly holding nothing
An administrativePower that is not a declared power, or that no role holdsAn account nobody could administer
One of tenancy’s five that no role holdsA route the capability ships that nobody could use
A nests entry that is not a declared role, or a pair where the stronger role is short a powerThe nesting is asserted, not documented
An unassignable entry that is not a declared roleSame
Every role excluded from assignmentNobody could ever be given one

Two more are refused later, because only the composed Worker knows them. A power a composed capability gates on that no role holds — media:manage beside media, storage:manage beside storage — is refused at compose, naming the power and the capability. And a catalog whose only administering roles are all unassignable leaves nobody who could found an account, which organization() refuses at assembly.

A role name is stable forever once a membership holds one

This is the rule to read twice.

Rows carry role names. Renaming coach to instructor does not migrate anybody: it orphans every membership holding the old name, and on the next request each of those people is decoded through a catalog that has never heard of their role and refused. Nothing in this capability can repair that, because nothing in it knows which new name an old one meant. Only you do, in a migration you write.

It is the same class of rule as the project name, which every Cloudflare resource name is computed from, and as a capability’s migrationOrder: a value recorded somewhere else that a later edit cannot reach back and change.

Adding a role is free, and so is changing what a role holds — the matrix is read fresh on every request and a demotion takes effect on the next one. Renaming or deleting a role is a data migration. Add the new role, move the rows, and only then take the old name out of the catalog.

So spell them the way you can live with. admin and member are names; admin_v2 and temp_role are regrets.

ESC