Organization reference

Config

organization({ … }) takes the config below, plus what a schema cannot carry: roles, ownership, onDelete, the four lifecycle hooks and the two membership hooks.

OptionDefaultWhat it decides
rolesrequiredThe catalog from defineRoles. Never defaulted: a starter matrix shipped from the kit would be role names you never chose. See roles and powers
ownershipabsent{ 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
onDeleteabsent(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/organizationsWhere 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/invitationsThe 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
invitationTtlDays14How long an invitation stays redeemable. At most 90
nominationTtlDays7How 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
allowSelfServicetruefalse refuses POST {base} for every caller, for a product where the operator provisions every tenant
sendInvitationEmailtrueMail invitations through the composed email. false hands the link back in the response. true with no email composed is refused at compose time
operatorEmailsall true{ memberAdded, memberRemoved, ownershipTransferred }, which notices an operator’s act sends. See membership administration
retention.purgeAfterDays30How long a deleted organization stays restorable before the purge erases it. See organization lifecycle
lifecycleEmailsall true{ organizationSuspended, organizationReinstated, organizationDeletionScheduled }, which notices the owners get when an operator suspends, reinstates or deletes the account
onOrganizationSuspended, onOrganizationReinstated, onOrganizationDeleted, onOrganizationPurgedabsent(event, d1) => Promise<void>, each fired once per transition that happened, after it is written. A throw is logged and swallowed
onMemberRoleChanged, onMemberRemovedabsent(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.

RouteGateWhat it does
GET {base}signed inThe organizations the caller may act in, which is in force, and how many of theirs are suspended
POST {base}signed inFound one, with the caller as its first member. Refused for everyone when allowSelfService is off
POST {base}/actingsigned inChoose the organization this session acts in
GET {base}/currentorganization:readThe organization in force
PATCH {base}/currentorganization:manageRename it, or set or clear its mark
DELETE {base}/currentorganization:deleteEnd it, and everything belonging to it
GET {base}/current/membersorganization:readThe roster
PATCH {base}/current/members/:membershipIdorganization:manageChange a role
DELETE {base}/current/members/:membershipIdorganization:manageRemove somebody
POST {base}/current/members/leavemembershipLeave
GET {base}/current/invitationsorganization:manageThe offers outstanding
POST {base}/current/invitationsorganization:manageInvite an address, at a role
POST {base}/current/invitations/:invitationId/resendorganization:manageSend one again with a fresh link
DELETE {base}/current/invitations/:invitationIdorganization:manageWithdraw one
GET {base}/invitations/:tokenpublicWhat an invitation offers, for the accept screen. Grants nothing
POST {base}/invitations/acceptsigned inAccept one, as the address it names
GET {base}/current/ownershipmembershipThe standing offer of ownership
POST {base}/current/ownershipholding the account, or volunteering yourself where nobody doesOffer ownership to a member
DELETE {base}/current/ownershipholding the account, or the offer being your ownWithdraw the offer
POST {base}/ownership/acceptthe nomineeAccept ownership
GET {base}/marks/organization/:organizationIdmembershipAn organization’s stored mark
GET {base}/members/:membershipId/imageorganization:readA member’s stored image
GET {base}/admin/organizationsorganization:accounts:readEvery tenant, newest first, with a member count, its seat limit, and where it stands: state, suspendedUntil, purgeAfter
GET {base}/admin/organizations/:organizationId/membersorganization:members:readOne tenant’s roster, with names and addresses
GET {base}/admin/rolesorganization:members:readEvery 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/:organizationIdorganization:accounts:writeRename a tenant, give it a free short name, set its seat limit
POST {base}/admin/organizations/:organizationId/membersorganization:members:writeAdd an existing user at a named role
PATCH {base}/admin/organizations/:organizationId/members/:membershipIdorganization:members:writeChange a member’s role
DELETE {base}/admin/organizations/:organizationId/members/:membershipIdorganization:members:writeRemove a member
GET {base}/admin/organizations/:organizationId/invitationsorganization:members:writeA tenant’s outstanding invitations
POST {base}/admin/organizations/:organizationId/invitations/:invitationId/resendorganization:members:writeSend one again with a fresh link
DELETE {base}/admin/organizations/:organizationId/invitations/:invitationIdorganization:members:writeRevoke one
POST {base}/admin/organizations/:organizationId/ownershiporganization:ownership:transferMake a member the owner now
POST {base}/admin/organizations/:organizationId/suspendorganization:accounts:suspendSuspend a tenant until a date or a reinstatement, and its sole members’ accounts too when asked
POST {base}/admin/organizations/:organizationId/suspend/previeworganization:accounts:suspendWho a suspension’s member cascade would reach, before it runs. Writes nothing
POST {base}/admin/organizations/:organizationId/reinstateorganization:accounts:suspendLift a tenant’s suspension
DELETE {base}/admin/organizations/:organizationIdorganization:accounts:deleteDelete a tenant: soft and restorable by default, hard to purge now
POST {base}/admin/organizations/:organizationId/delete/previeworganization:accounts:deleteWho a deletion’s member cascade would reach, before it runs. Writes nothing
POST {base}/admin/organizations/:organizationId/restoreorganization:accounts:deleteRestore 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

ScopeKindCovers
organization:accounts:readreadsThe account list: names, short names, member counts, seat limits, and each account’s state. No addresses
organization:members:readreadsOne account’s roster, with the people on it — names and addresses — and the role catalog a roster write names
organization:members:writewritesWho 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:writewritesWhat an account is: its name, its short name, its seat limit
organization:ownership:transferwritesWho owns it, moved now with no nomination and no acceptance
organization:accounts:suspendwritesSuspend an account and reinstate it, and preview who a suspension’s cascade would reach. Nobody can act in it meanwhile
organization:accounts:deletedeletesDelete 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.

CodeStatusWhen
organization/not_found404No 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/forbidden403A 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_suspended403A proved member acting in, choosing or naming a suspended organization. params.suspendedUntil names when it ends, when it does
organization/invitation_invalid400An invitation that cannot be redeemed or acted on, whatever the reason — an organization suspended or deleted among them
organization/nomination_invalid400No offer of ownership this caller may accept
organization/last_administrator409The write would leave nobody holding administrativePower
organization/last_owner409An operator’s write would leave nobody holding the owning role. params.ownershipRoute names the route that moves it
organization/already_member409An operator added somebody already in the account. params.role names what they hold
organization/seat_limit_reached409The account is at its seat limit. params.seatLimit names it
organization/slug_taken409Another account holds that short name
organization/invalid_role_catalog400A 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.

ActionRecorded when
organization/createdAn organization was founded, and its founder became its first member
organization/renamedThe display name changed
organization/logo_changedThe mark was set, replaced or taken off
organization/deletedThe organization was deleted, with every membership, invitation and selection
organization/member_invitedSomebody was invited
organization/invitation_resentAn invitation was sent again with a fresh link
organization/invitation_revokedAn outstanding invitation was withdrawn
organization/member_joinedAn invitation was accepted and a membership created
organization/member_role_changedA member’s role changed
organization/member_removedA member was removed by somebody else
organization/member_leftA member removed themselves
organization/ownership_nominatedOwnership was offered to a member
organization/ownership_withdrawnAn offer of ownership was withdrawn
organization/ownership_acceptedA nominee accepted, and the account changed hands
organization/admin_member_addedAn operator added an existing user, at a named role
organization/admin_member_removedAn operator removed a member, or a forced transfer with remove ended a previous owner’s membership
organization/admin_member_role_changedAn operator changed a role, or a forced transfer with keep demoted a previous owner
organization/admin_ownership_forcedAn operator made a member the owner. Recorded at warning
organization/admin_organization_updatedAn operator renamed the account, moved its short name or set its seat limit
organization/admin_invitations_listedAn operator read the outstanding invitations. Records a count, never the rows
organization/admin_invitation_resentAn operator sent an invitation again
organization/admin_invitation_revokedAn operator revoked an invitation
organization/admin_organization_suspendedAn operator suspended the account, or restated a suspension in force. Carries how many members a cascade reached and skipped
organization/admin_organization_reinstatedAn operator lifted the suspension
organization/admin_organization_deletedAn operator deleted the account softly, restorable until its window closes
organization/admin_organization_hard_deletedAn operator deleted it hard: the window closed at once and the purge started
organization/admin_organization_restoredAn operator restored a deleted account inside its window
organization/purge_stepOne 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_failedA purge gave up at the step metadata.step names, until the next daily pass starts it again
organization/purgedThe purge erased the account from every capability that held it. The last event it gets
organization/refusedSomebody 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_organizationsOne row per account: name, short name, mark, seat limit, and its state — suspended_at, suspended_until, deleted_at, purge_after
pithy_organization_membershipsOne row per person per account, with their role as text
pithy_organization_invitationsOffers of membership: the invited address, the role, the token’s digest, the status, the expiry
pithy_organization_ownership_nominationsThe one standing offer of ownership per account
pithy_organization_actingWhich organization each session is acting in, keyed by session id
pithy_organization_lifecycleEvery 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.

ESC