pithy add organization --with-prerequisitesOrganization declares one peer capability, auth, and the Worker refuses to assemble without it, naming it. Auth brings its own two, secrets and email, so on a fresh project the flag composes all three before this one. Without it a terminal asks once, and anything headless is refused with the exact commands in order.
What lands in your repo
apps/<worker>/src/organization/roles.ts is written with a starter matrix, and only when the file is absent. It is yours from that moment: a later pithy add never rewrites it, and the run prints a note telling you to edit it before anybody signs in.
export const roles = defineRoles({
powers: [],
roles: {
member: ["organization:read"],
admin: ["organization:read", "organization:manage", "members:manage", "media:manage", "storage:manage"],
owner: [
"organization:read",
"organization:manage",
"members:manage",
"billing:manage",
"organization:delete",
"media:manage",
"storage:manage",
],
},
administrativePower: "organization:manage",
nests: ["member", "admin", "owner"],
unassignable: ["owner"],
});It is a starter, not your matrix. Edit it before anybody signs in, because a role name is stable forever once a membership holds one. Renaming member later orphans every row that carries it. Roles and powers explains every field.
apps/<worker>/pithy.config.ts gains the import and an organization({ … }) registration inside the managed region, passing roles from that module.
apps/<worker>/wrangler.jsonc gains one binding, DB, the D1 database auth already uses. Both sets of tables have to be in one database, because the roster resolves people through auth’s published reader.
No handler code lands anywhere. The routes, the gates and the stores stay in the package and upgrade with it.
What runs
add runs that Worker’s dev migrations, which create six tables, all pithy_organization_*:
organizations · memberships · invitations · ownership_nominations · acting · lifecycle
Nothing is minted. Organization has no secret of its own.
What you still decide
Four things the starter cannot know.
Whether anybody owns an account. The starter excludes owner from assignment, but the two-party transfer only mounts when you name the roles it moves:
organization({
roles,
ownership: { confers: "owner", demotesTo: "admin" },
}),Omit ownership and the ownership routes do not mount, which is the right shape for a product where nobody signs for a bill. Name it and the pair is checked at compose time: confers must be unassignable and must administer, and demotesTo must be a different declared role.
Where invitation links point. Set baseUrl to the origin your Worker answers on, per environment — an email cannot carry a relative link, and the invite route refuses before it mints an offer. Serve a page at invitationAcceptPath, /invitations by default; the token arrives as the last segment. Using Organization has the page’s two calls.
How invitations are delivered. sendInvitationEmail defaults to true, and then the Worker is refused at compose time unless email is composed beside it. Set it to false to hand the accept link back to your own code instead.
Whether a deleted account is ever erased. An operator’s deletion shuts the account at once, but the purge that erases it runs as two Workflows your Worker exports and binds, beside a daily cron. Until they are bound, a deletion still shuts the account and nothing is erased. Organization lifecycle has the two classes and the wrangler.jsonc entries.
One line of wiring you write yourself
The acting selection is keyed by session id, so it should go when the session does. Nothing in this capability can see a sign-out — tenancy depends on auth, not the reverse — so you wire auth’s listener to it:
import { clearActing } from "@pithy-sh/organization/src/acting/acting";
import { organizationDatabase } from "@pithy-sh/organization/src/data/tables";
auth({
// Fires on a sign-out, a revoke, an admin ending somebody's devices: anything that deletes the session row.
onSessionRevoked: async ({ id }, d1) => {
await clearActing(organizationDatabase(d1), { sessionId: id });
},
}),Without it the row outlives the credential that made it, one per sign-in, in a table with no expiry and no sweep. It confers nothing — every read re-joins memberships and matches the user id too — so this is growth rather than an access question.
A rotation is not an ending, and needs no wiring. A bearer client’s session token is replaced each time it rotates, and the new session would start with no organization in force. So the capability declares sessionRotated, auth announces each rotation through core, and the selection moves to the new session id for the same user. Compose both and it happens.
Check it worked
pithy devThe Worker boots, and pithy doctor reports organization with its binding satisfied and its migrations applied. A catalog mistake — a nesting claim that does not hold, a kit power you redeclared, an ownership pair that names an assignable role — refuses the Worker at that point, naming what is wrong.