Using Organization

Three gates on your own routes, a selection a session acts in, and the routes the capability ships for everything else. {base} below is the configured basePath, /organizations by default. Every route the capability ships is in the reference.

Gate your own routes

Compose the gates on the route line, in this order: who is signed in, which membership is in force, and whether its role holds the power.

import type { D1Database } from "@cloudflare/workers-types";
import { requireAuth } from "@pithy-sh/auth/src/http/middleware";
import { describeRoute } from "@pithy-sh/core/src/http/routeSpec";
import { validate } from "@pithy-sh/core/src/http/validation";
import { organizationDatabase } from "@pithy-sh/organization/src/data/tables";
import { requireOrganization, requirePower } from "@pithy-sh/organization/src/http/guard";
import { roles } from "./organization/roles";

const deps = {
  catalog: roles,
  database: (env: Record<string, unknown>) => organizationDatabase(env.DB as D1Database),
};

app.get(
  "/coaches",
  describeRoute({ summary: "List the coaches.", verification: ["bearer", "session"], responses: { 200: CoachList } }),
  requireAuth(),
  requireOrganization(deps),            // fills c.var.acting, or 404s
  requirePower("sessions:read", deps),  // asks the matrix, or 403s
  validate("query", ListCoaches),
  handler,
);

c.var.acting exists only downstream of requireOrganization(), and it is non-optional there, so a handler reads it without a null check:

{ organizationId, slug, name, userId, role, chosen }

role narrows to the union your catalog declared, and requirePower refuses a power nobody declared at compile time rather than denying everyone at runtime. chosen says whether somebody picked this account; nothing authorizes on it.

A 404 and a 403 mean different things here. requireOrganization() answers organization/not_found (404) for a caller with no membership in force, and for one naming an account they are not in — the same bytes as an account that does not exist. requirePower() answers organization/forbidden (403), because by then the caller has been proved a member and already knows the account exists. A proved member of an account an operator has suspended gets organization/organization_suspended (403) from requireOrganization() instead, and a deleted account is the 404; organization lifecycle has both.

Then scope your own queries. This capability cannot see your tables. Every query against a tenanted table of yours should join memberships on both halves of c.var.acting — the user and the organization — so no arrangement of callers reaches another tenant’s rows. The package’s docs/scoping-your-own-tables.md has the pattern and the test that proves it.

Choose the organization in force

The organization in force is session state, never a path segment. A URL that cannot name another tenant cannot be pointed at one.

  1. GET {base} lists every organization the caller may act in, ordered by name, each with their role. Beside the list it answers acting — the id in force, or null — chosen, and suspended, how many of the caller’s organizations are suspended and so left off the list.
  2. POST {base}/acting with { organizationId } proves a membership and writes the selection, keyed by the session. An account the caller is not in is the same 404 as one that does not exist.

Until a session has chosen, every {base}/current route and every route behind requireOrganization() answers 404 with No organization is in force. The action says choose an organization when the caller belongs somewhere, and join or create one when they belong nowhere, so a client can tell those two apart without either naming an account.

The selection is a preference; the membership is the authority. Every request re-joins memberships, so a selection whose membership has been deleted grants nothing and reads as no selection.

A bearer client keeps its selection across a rotation. Its session token is replaced each time it rotates, and the selection moves to the new session for the same user, with no wiring: Add Organization has why.

Founding one does not move it. POST {base} creates an organization with the caller as its first member, at the first role in your catalog that administers and may be assigned, and leaves the selection where it was. Creating a second account from a settings pane must not move somebody out of the one they were working in. slug is optional; omitted, it is derived from the name. allowSelfService: false refuses the route for every caller, and slugs: "derived" refuses a supplied slug.

Run the roster

RoutePowerWhat it does
GET {base}/current/membersorganization:readThe roster, oldest first, with names and addresses resolved through auth’s published reader
PATCH {base}/current/members/:membershipIdorganization:manageChange a role, to one the catalog lets anybody assign
DELETE {base}/current/members/:membershipIdorganization:manageRemove somebody, and clear their selection of the account
POST {base}/current/members/leave—Leave. A different act from being removed, and recorded as one

Minting an administrator takes a second power. Handing somebody a role that holds your administrativePower — by a role change or by an invitation — also needs members:manage. Running the roster day to day does not.

The account always keeps somebody who can administer it. The last holder of administrativePower cannot be demoted, removed or leave; each answers organization/last_administrator (409). Nobody can take away their own administration either.

A role nobody may assign does not change here. Somebody holding an unassignable role cannot be demoted, removed or leave through these routes, and nobody can be given one. In the starter catalog that is owner: an owner who wants out hands the account on first.

Invite somebody

POST {base}/current/invitations with { email, role } makes an offer, under organization:manage. The role has to be assignable, and an administering one needs members:manage too. Inviting an address that already holds a live offer supersedes it, so there is one live link per address. An offer lasts invitationTtlDays, 14 by default.

With sendInvitationEmail on, the link is mailed and the response’s acceptUrl is null. With it off, nothing is mailed and acceptUrl carries the link for your own code to deliver. GET {base}/current/invitations lists the outstanding offers, POST …/:invitationId/resend mints a fresh link and kills the old one, and DELETE …/:invitationId withdraws one.

The link goes to your app, not to this capability. It is baseUrl plus invitationAcceptPath plus the token, so the page you serve is /invitations/:token by default. That page makes two calls:

  • GET {base}/invitations/:token is public and grants nothing. It answers what the offer is — organizationName, inviterName, role, expiresAt — for the screen to show.
  • POST {base}/invitations/accept with { token }, from a signed-in session, redeems it.

readInvitation(token) and acceptInvitation(token) in @pithy-sh/organization/src/client/api make the two calls, and the --organization screens of pithy ui add react and pithy ui add react-native include the page. Accepting does not move the organization in force: call chooseOrganization with the organizationId the accept answers if the reader should act in it. A client off the Worker’s origin, a native app, passes origin and a fetch that adds its bearer credential.

Accepting needs a session whose own address is the invited one. That is the property that makes a forwarded link useless. Every way an offer cannot be redeemed — no such token, used, withdrawn, expired, a role the catalog no longer assigns, the wrong address, an account suspended or deleted — is one answer, organization/invitation_invalid (400), so nobody holding a forwarded link learns which it was.

Redeeming twice is not an error: the second answers joined: false with the membership already held. Accepting does not move the acting selection either. An account at its seat limit refuses the join with organization/seat_limit_reached, and the offer stays pending until a seat frees.

Transfer ownership

These routes mount only where you passed an ownership pair. The role it confers is unassignable, so nothing but this transfer — or an operator — can give it to anybody.

  1. Offer. POST {base}/current/ownership with { membershipId }. While somebody holds the account, only they may offer it. While nobody does — the ordinary state of a young account — somebody who administers it may volunteer themselves, and may appoint nobody else.
  2. Accept. The nominee calls POST {base}/ownership/accept. That is the transfer: the previous holders are demoted to demotesTo, the nominee takes confers, and the offer is spent, in one write.

GET {base}/current/ownership reads the standing offer for any member, so the nominee can see it. DELETE {base}/current/ownership withdraws it: the holder may, or, in an account nobody holds, the person who volunteered. One account offers itself to one person at a time, so offering again replaces the offer, and an offer lasts nominationTtlDays, 7 by default.

No offer, an expired one, one whose nominee left, and one made to somebody else all answer organization/nomination_invalid (400), the same sentence.

An account whose owner has gone is stuck on this surface, deliberately. No route a member can reach repairs it. An operator can, through membership administration, under its own scope and with a reason.

Rename it, mark it, end it

PATCH {base}/current renames the account or sets its mark, under organization:manage. A mark is a stored data: image, never a remote URL, and null takes it off. The short name does not change here; only an operator moves it.

DELETE {base}/current, under organization:delete, ends the account and everything belonging to it: its memberships, invitations, offers and selections. Your own tenanted rows go in the same transaction when you hand them over:

organization({
  roles,
  onDelete: (d1, organizationId) => [
    myDatabase(d1).deleteFrom("projects").where("organizationId", "=", organizationId),
  ],
}),

Return statements rather than doing the work. They join the same d1.batch as the capability’s own deletes and run first, so a failure anywhere rolls the account back too.

What the trail records

When audit is composed, every administrative act writes an organization/* event with the member who took it: founding, renaming, a new mark, deletion, every invitation and membership change, and each step of a transfer. A refusal from somebody already acting in the account — a role reaching above itself, an attempt to remove the last administrator, an offer that cannot be redeemed — is one organization/refused event with the refusal’s code in its facts. organization/not_found is never recorded, because anybody can produce it at will by naming a UUID. No event carries an address. The reference lists every code.

ESC