Config
organization({ … }) takes the config below, plus what a schema cannot carry: roles, ownership, onDelete, the four lifecycle hooks and the two membership hooks.
| Option | Default | What it decides |
|---|---|---|
roles | required | The catalog from defineRoles. Never defaulted: a starter matrix shipped from the kit would be role names you never chose. See roles and powers |
ownership | absent | { confers, demotesTo }, the two roles a transfer moves. Absent, no ownership route mounts. confers must be unassignable and administer; demotesTo must be another declared role |
onDelete | absent | (d1, organizationId) => statements[], your own tenanted deletes, run first in the batch that deletes the account, whether a member ends it or the purge erases it |
basePath | /organizations | Where the routes mount. Changing it changes every API address this capability serves |
baseUrl | — | The absolute origin an invitation link is built from. Required before an invitation can be sent |
invitationAcceptPath | /invitations | The page in your app an invited person is sent to. The token is appended as a path segment. Change it and already-sent links break |
invitationTtlDays | 14 | How long an invitation stays redeemable. At most 90 |
nominationTtlDays | 7 | How long an offer of ownership stands. At most 90 |
slugs | "chosen" | "chosen" lets a create request carry a slug; "derived" refuses one. Either way an omitted slug is derived from the name |
allowSelfService | true | false refuses POST {base} for every caller, for a product where the operator provisions every tenant |
sendInvitationEmail | true | Mail invitations through the composed email. false hands the link back in the response. true with no email composed is refused at compose time |
operatorEmails | all true | { memberAdded, memberRemoved, ownershipTransferred }, which notices an operator’s act sends. See membership administration |
retention.purgeAfterDays | 30 | How long a deleted organization stays restorable before the purge erases it. See organization lifecycle |
lifecycleEmails | all true | { organizationSuspended, organizationReinstated, organizationDeletionScheduled }, which notices the owners get when an operator suspends, reinstates or deletes the account |
onOrganizationSuspended, onOrganizationReinstated, onOrganizationDeleted, onOrganizationPurged | absent | (event, d1) => Promise<void>, each fired once per transition that happened, after it is written. A throw is logged and swallowed |
onMemberRoleChanged, onMemberRemoved | absent | (event, d1) => Promise<void>, fired once per member whose role changed or whose membership ended, through any route, after it is written and recorded. A role change carries previousRole, role and powersLost; a removal carries the role held and left. A throw is logged and swallowed. See organization lifecycle |
retention.purgeAfterDays is the default a Worker’s policy can replace. When the retention block in the Worker’s pithy.config.ts names purgeWindows.organization, that window wins, and the manifest and pithy doctor say which one is in force: retention and legal holds.
Routes
{base} is basePath. The member routes verify a bearer token or a session; every mutating one is CSRF-guarded. The control-plane routes are reached by a registered connection, each under the scope it names.
| Route | Gate | What it does |
|---|---|---|
GET {base} | signed in | The organizations the caller may act in, which is in force, and how many of theirs are suspended |
POST {base} | signed in | Found one, with the caller as its first member. Refused for everyone when allowSelfService is off |
POST {base}/acting | signed in | Choose the organization this session acts in |
GET {base}/current | organization:read | The organization in force |
PATCH {base}/current | organization:manage | Rename it, or set or clear its mark |
DELETE {base}/current | organization:delete | End it, and everything belonging to it |
GET {base}/current/members | organization:read | The roster |
PATCH {base}/current/members/:membershipId | organization:manage | Change a role |
DELETE {base}/current/members/:membershipId | organization:manage | Remove somebody |
POST {base}/current/members/leave | membership | Leave |
GET {base}/current/invitations | organization:manage | The offers outstanding |
POST {base}/current/invitations | organization:manage | Invite an address, at a role |
POST {base}/current/invitations/:invitationId/resend | organization:manage | Send one again with a fresh link |
DELETE {base}/current/invitations/:invitationId | organization:manage | Withdraw one |
GET {base}/invitations/:token | public | What an invitation offers, for the accept screen. Grants nothing |
POST {base}/invitations/accept | signed in | Accept one, as the address it names |
GET {base}/current/ownership | membership | The standing offer of ownership |
POST {base}/current/ownership | holding the account, or volunteering yourself where nobody does | Offer ownership to a member |
DELETE {base}/current/ownership | holding the account, or the offer being your own | Withdraw the offer |
POST {base}/ownership/accept | the nominee | Accept ownership |
GET {base}/marks/organization/:organizationId | membership | An organization’s stored mark |
GET {base}/members/:membershipId/image | organization:read | A member’s stored image |
GET {base}/admin/organizations | organization:accounts:read | Every tenant, newest first, with a member count, its seat limit, and where it stands: state, suspendedUntil, purgeAfter |
GET {base}/admin/organizations/:organizationId/members | organization:members:read | One tenant’s roster, with names and addresses |
GET {base}/admin/roles | organization:members:read | Every role the catalog declares, in its order, and owningRole, the role the roster writes refuse, or null where the project wires no ownership |
PATCH {base}/admin/organizations/:organizationId | organization:accounts:write | Rename a tenant, give it a free short name, set its seat limit |
POST {base}/admin/organizations/:organizationId/members | organization:members:write | Add an existing user at a named role |
PATCH {base}/admin/organizations/:organizationId/members/:membershipId | organization:members:write | Change a member’s role |
DELETE {base}/admin/organizations/:organizationId/members/:membershipId | organization:members:write | Remove a member |
GET {base}/admin/organizations/:organizationId/invitations | organization:members:write | A tenant’s outstanding invitations |
POST {base}/admin/organizations/:organizationId/invitations/:invitationId/resend | organization:members:write | Send one again with a fresh link |
DELETE {base}/admin/organizations/:organizationId/invitations/:invitationId | organization:members:write | Revoke one |
POST {base}/admin/organizations/:organizationId/ownership | organization:ownership:transfer | Make a member the owner now |
POST {base}/admin/organizations/:organizationId/suspend | organization:accounts:suspend | Suspend a tenant until a date or a reinstatement, and its sole members’ accounts too when asked |
POST {base}/admin/organizations/:organizationId/suspend/preview | organization:accounts:suspend | Who a suspension’s member cascade would reach, before it runs. Writes nothing |
POST {base}/admin/organizations/:organizationId/reinstate | organization:accounts:suspend | Lift a tenant’s suspension |
DELETE {base}/admin/organizations/:organizationId | organization:accounts:delete | Delete a tenant: soft and restorable by default, hard to purge now |
POST {base}/admin/organizations/:organizationId/delete/preview | organization:accounts:delete | Who a deletion’s member cascade would reach, before it runs. Writes nothing |
POST {base}/admin/organizations/:organizationId/restore | organization:accounts:delete | Restore a deleted tenant inside its window, with every membership it had |
The five ownership routes mount only where ownership is passed — the four member routes and the forced-ownership route — and only then does the manifest advertise the last one. Every control-plane write takes a reason, and so do the two previews, which take their write’s own body, and the invitation listing, in its query. The tenant listing pages on a cursor: an optional limit, 1 to 100, and cursor, the last page’s nextCursor, which is null at the end. It narrows on the server with state, active, suspended or deleted, judged as every door judges it, so a suspension past its end is active. One tenant’s roster takes an optional limit, 1 to 100, and answers truncated when there were more. The role catalog is read from config, so it answers the same against an empty database: a management client offers roles without owningRole, and never a role it only saw on a roster. What each membership route does, and refuses, is membership administration; the last six rows are organization lifecycle.
ORGANIZATION_ROUTES in src/http/routes.ts is the registry this table mirrors, and the package’s route-contract test compares it against what Hono actually mounted, in both directions.
Control-plane scopes
| Scope | Kind | Covers |
|---|---|---|
organization:accounts:read | reads | The account list: names, short names, member counts, seat limits, and each account’s state. No addresses |
organization:members:read | reads | One account’s roster, with the people on it — names and addresses — and the role catalog a roster write names |
organization:members:write | writes | Who is in an account: add, remove, change a role, and list, resend or revoke an invitation. Never a move into or out of the owning role |
organization:accounts:write | writes | What an account is: its name, its short name, its seat limit |
organization:ownership:transfer | writes | Who owns it, moved now with no nomination and no acceptance |
organization:accounts:suspend | writes | Suspend an account and reinstate it, and preview who a suspension’s cascade would reach. Nobody can act in it meanwhile |
organization:accounts:delete | deletes | Delete an account, softly or hard, restore one inside its window, and preview who a deletion’s cascade would reach |
Matched exactly, with no prefix rule, so none implies another and none implies a read. The kind is what the manifest carries beside each route’s scope, for a management client’s own roles. Scopes explains why each is split where it is.
Error codes
Read the way every error code in the kit is.
| Code | Status | When |
|---|---|---|
organization/not_found | 404 | No such organization, or none this caller belongs to, byte for byte. Also no organization in force for the session, a membership id that is not in the account, and a deleted organization, to a member as to a stranger. After a purge its action says the data is gone |
organization/forbidden | 403 | A proved member whose role does not hold the power, or who may not make this change — an unassignable role, minting an administrator without members:manage, an ownership offer they may not make |
organization/organization_suspended | 403 | A proved member acting in, choosing or naming a suspended organization. params.suspendedUntil names when it ends, when it does |
organization/invitation_invalid | 400 | An invitation that cannot be redeemed or acted on, whatever the reason — an organization suspended or deleted among them |
organization/nomination_invalid | 400 | No offer of ownership this caller may accept |
organization/last_administrator | 409 | The write would leave nobody holding administrativePower |
organization/last_owner | 409 | An operator’s write would leave nobody holding the owning role. params.ownershipRoute names the route that moves it |
organization/already_member | 409 | An operator added somebody already in the account. params.role names what they hold |
organization/seat_limit_reached | 409 | The account is at its seat limit. params.seatLimit names it |
organization/slug_taken | 409 | Another account holds that short name |
organization/invalid_role_catalog | 400 | A catalog or an ownership pair failed validation, at author or compose time |
Three codes from other domains reach these routes. controlplane/insufficient_scope (403) refuses an operator’s roster write that would move the owning role, naming the ownership route in params.ownershipRoute. core/conflict (409) refuses a write whose subject’s role changed while it ran, a forced transfer to somebody who already owns the account, a suspension or reinstatement of a deleted organization, naming the restore route, and a restore once the purge window has closed. validation/invalid_input (400) refuses a malformed body, an operator naming a role the catalog does not declare, a member’s role change to a role nobody may assign, and a suspension whose until is not in the future. An invitation offering such a role is organization/forbidden instead.
Audit events
Every code is organization/*. The member acts are recorded against the member who took them; the operator acts with actorType: "control-plane", the token’s sub as the actor, the connection and the reason. No event carries an email address.
| Action | Recorded when |
|---|---|
organization/created | An organization was founded, and its founder became its first member |
organization/renamed | The display name changed |
organization/logo_changed | The mark was set, replaced or taken off |
organization/deleted | The organization was deleted, with every membership, invitation and selection |
organization/member_invited | Somebody was invited |
organization/invitation_resent | An invitation was sent again with a fresh link |
organization/invitation_revoked | An outstanding invitation was withdrawn |
organization/member_joined | An invitation was accepted and a membership created |
organization/member_role_changed | A member’s role changed |
organization/member_removed | A member was removed by somebody else |
organization/member_left | A member removed themselves |
organization/ownership_nominated | Ownership was offered to a member |
organization/ownership_withdrawn | An offer of ownership was withdrawn |
organization/ownership_accepted | A nominee accepted, and the account changed hands |
organization/admin_member_added | An operator added an existing user, at a named role |
organization/admin_member_removed | An operator removed a member, or a forced transfer with remove ended a previous owner’s membership |
organization/admin_member_role_changed | An operator changed a role, or a forced transfer with keep demoted a previous owner |
organization/admin_ownership_forced | An operator made a member the owner. Recorded at warning |
organization/admin_organization_updated | An operator renamed the account, moved its short name or set its seat limit |
organization/admin_invitations_listed | An operator read the outstanding invitations. Records a count, never the rows |
organization/admin_invitation_resent | An operator sent an invitation again |
organization/admin_invitation_revoked | An operator revoked an invitation |
organization/admin_organization_suspended | An operator suspended the account, or restated a suspension in force. Carries how many members a cascade reached and skipped |
organization/admin_organization_reinstated | An operator lifted the suspension |
organization/admin_organization_deleted | An operator deleted the account softly, restorable until its window closes |
organization/admin_organization_hard_deleted | An operator deleted it hard: the window closed at once and the purge started |
organization/admin_organization_restored | An operator restored a deleted account inside its window |
organization/purge_step | One purge step answered for a capability’s rows. metadata.step names the capability and metadata.disposition says erased or retained. The erasure attestation cites it |
organization/purge_failed | A purge gave up at the step metadata.step names, until the next daily pass starts it again |
organization/purged | The purge erased the account from every capability that held it. The last event it gets |
organization/refused | Somebody acting in the account was refused. facts.refusal is the code |
organization/refused records four refusals: organization/forbidden, organization/last_administrator, organization/invitation_invalid and organization/nomination_invalid, each from a session with an organization in force. organization/not_found is never recorded, because anybody can produce it by naming a UUID.
An operator’s scope refusal is the control plane’s to record. A roster write refused for moving the owning role writes controlplane/call_denied with refusedBy: "handler", not an organization/* event.
Every event that changes a role says what it was before. A role change, either ownership event and the operator’s role change carry previousRole beside role, and every member event names the member as subjectUserId, so the access history replays who held what without a guess. An accepted transfer also writes an organization/member_role_changed for each previous holder it demoted, as a forced transfer already did with its admin_* events.
Tables
All six in the app D1, bound as DB, beside auth’s.
| Table | |
|---|---|
pithy_organization_organizations | One row per account: name, short name, mark, seat limit, and its state — suspended_at, suspended_until, deleted_at, purge_after |
pithy_organization_memberships | One row per person per account, with their role as text |
pithy_organization_invitations | Offers of membership: the invited address, the role, the token’s digest, the status, the expiry |
pithy_organization_ownership_nominations | The one standing offer of ownership per account |
pithy_organization_acting | Which organization each session is acting in, keyed by session id |
pithy_organization_lifecycle | Every lifecycle transition, with its actor, reason and time, and each purge step. No foreign key, so it outlives the purge |
Imports
@pithy-sh/organization/src/index exports organization, defineRoles, requireOrganization, requirePower, organizationDatabase, requireMayAssign, organizationRefusal and the seven scope constants, among others. Three are deep imports: clearActing from @pithy-sh/organization/src/acting/acting; runOrganizationPurge and runOrganizationPurgeDue from @pithy-sh/organization/src/purge/workflow, the bodies of the two purge Workflows your Worker exports; and deriveSlug from @pithy-sh/organization/src/data/slug, which previews the short name a form will get. The server derives it again, and the unique index settles it.
License
MIT. The root LICENSE covers it.