Organization overview

Every multi-tenant product grows the same model sooner or later: accounts, the people in them, what each of those people may do, a way to invite somebody, and a way to hand the account to somebody else when the person who signed for it moves on. You write it once, then you write it again when the second product has a different idea of what a role is.

@pithy-sh/organization is that model, in your own D1. Organizations, memberships, a role catalog you declare, invitations bound to an address, ownership that moves only when somebody accepts it, and a record of which account each session is acting in. It is the model the Pithy dashboard runs on, generalized.

You declare the roles

A dashboard’s member, admin and owner nest: every power a member holds, an admin holds too. A coaching academy’s coach and student do not — a coach accepts sessions, a student requests them, and neither is a weaker version of the other. A capability that assumed either shape would refuse the other outright.

So the catalog is yours. You write it with defineRoles in src/organization/roles.ts, a module rather than a config literal, because your own handlers import the powers it declares. Seven power names are the kit’s: tenancy’s five, which this capability’s own routes gate on, and media:manage and storage:manage. Yours sit beside them under your own names. Roles and powers has the whole of it, including the one rule to read twice: a role name is stable forever once a membership holds one.

Three gates on the route line

Nothing is implicit. A route that did not mount requireOrganization() has no organization, and a route that did has one that was proved on this request.

  • requireAuth() says who is signed in.
  • requireOrganization() proves a membership in the organization this session is acting in, and puts it on c.var.acting. It answers 404 otherwise.
  • requirePower("...") asks your matrix whether that membership’s role holds a power. It answers 403 otherwise.

c.var.acting is a second context variable, not a field on auth. Signed in and a member of this organization are two conditions, and merging them would make them one.

What a membership is worth

A caller naming an organization they are not in gets the 404 for one that does not exist, byte for byte. A distinguishable refusal is an existence oracle, and iterating it produces your customer list. The difference is real and rides in detail, which the HTTP codec strips and the log keeps.

A role is decoded, never asserted. The column is text, because the catalog is yours. Every read parses it through the catalog, so a value nobody declared refuses while there is still a request to refuse, rather than denying everything today and allowing it after one refactor.

Nothing about a role rides on the session. Only the id of the chosen organization does. Deleting a membership row is the whole of revocation, and it takes effect on the next request with no sign-out and no cache to expire.

What you get without writing any of it

  • A roster. List the members, change a role, remove somebody, and leave — which is a different act from being removed, and is recorded as one. The last person who can administer the account cannot be demoted, removed or leave.
  • Invitations bound to an address. The token is 256 random bits and only its digest is stored, but the property that matters is that accepting one needs a signed-in session whose own address is the invited one. A forwarded link grants nothing.
  • Ownership that moves by offer and acceptance. Where your catalog has an owner who pays, nobody is made responsible for an account by somebody else’s click. Using Organization walks the two-party transfer.
  • An acting selection per session, for people who belong to more than one account, with a chooser’s worth of routes behind it.
  • Membership administration from the control plane. Support can add or remove a member, change a role, fix an invitation, hold a seat limit and, behind its own scope, force ownership when the owner has gone. Every one takes a reason and is audited. Membership administration has the rules.
  • Suspension and deletion. An operator can suspend an account, reinstate it, delete it softly with a window or hard, and restore it, each with a reason. Nobody can act in it meanwhile, and a member of several accounts carries on in the others. A purge erases a deleted account from organization, media and storage, and from your own tables when you declare them. Organization lifecycle has the rules.
  • An audit trail of every administrative act, written through core’s emit seam when audit is composed, with no address in any row.

What it deliberately does not do

No permission grants to a single person. A power is what a role holds. Nothing grants one to one member, and AuthContext.scopes is a different seam — an attestation that a write came from your backend, not a permission on a user.

No teams, and no organizations inside organizations. A nesting nobody needs is a second tenancy boundary every query then has to remember.

No billing mechanics. Payments already bills an organization subject. This capability answers which organization. billing:manage is a power over the account’s billing relationship, not a payment rail.

No SSO, SCIM or domain-based auto-join. An address matching a domain is not consent.

No scoping of your own tables. It cannot see them. It proves the membership and hands you { organizationId, userId } on c.var.acting; every query against a tenanted table of yours should still join memberships on both. The package’s own docs/scoping-your-own-tables.md has the pattern, and the onDelete seam deletes your rows in the same transaction as the account.

Not Better Auth’s organization() plugin. That plugin puts free-string roles on a member row and one nullable active organization on the session. Composing both in one Worker is refused at compose time, naming each, because two membership models is two answers to whether somebody may act.

When you would reach for it

When more than one person shares an account and they should not all be able to do the same things. A team workspace, a school with teachers and students, an agency managing clients: each is one organization with several members at different roles.

A product where every account is one person does not need it. Auth already says who they are.

What it needs

It requires auth: a membership is keyed by a user id this capability does not own, and every route but the public invitation screen begins with a session. Both live in the same D1, bound as DB.

It gets better with email, which mails invitations, the notices an operator’s act sends and the owners’ lifecycle notices, with audit, which records every membership change, and with the control plane, which opens the management routes. Without email, set sendInvitationEmail: false and deliver the link yourself; with it left on and no email composed, the Worker is refused at compose time. The organization purge runs as two Workflows your Worker exports, as auth’s does; until they are bound, a deletion still shuts the account and nothing is erased.

ESC