AUTH4D-138 Owner invitations, promotion to owner and ownership transfer
This change extends the AUTH4D-70 (repository file docs/api-spec/auth4d-70.md) members and invitations routes so that ownership
can be shared and handed over. Owners can invite an owner, make a member an owner, and transfer
ownership in one step. Admins and viewers get 403 insufficient_role for all three. The console
Team screen (AUTH4D-71 (repository file docs/api-spec/auth4d-71.md)) calls these routes through its backend-for-frontend, which
applies the same-origin (CSRF) check to every mutation.
All routes are under /management/tenants/{tenantId}, need a management token, and return JSON with
Cache-Control: no-store. Every query and update is keyed on tenant_id.
Role rules
| Action | Owner | Admin | Viewer |
|---|---|---|---|
Invite with role: "owner" |
Yes | No (403) | No |
| Revoke an owner invitation | Yes | No (403) | No |
PATCH /members/{id} to owner |
Yes | No (403) | No |
PATCH /members/{id} from owner |
Yes | No (403) | No |
POST /ownership-transfer |
Yes | No (403) | No |
| Remove or demote the last owner | No (409 last_owner) |
No | No |
The last-owner rule holds in both the policy check and the SQL that applies a change. A project can have several owners.
Contracts
These changes in packages/contracts are additive: every request that was valid before is still
valid.
CreateProjectInvitationRequestSchema.role,ProjectInvitationSchema.role,AcceptProjectInvitationResponseSchema.roleandUpdateProjectMemberRoleRequestSchema.rolenow take anyProjectRoleSchemavalue (owner,adminorviewer).InvitableProjectRoleSchemais unchanged and still lists the roles admins and owners could invite before this change.- New
TransferProjectOwnershipRequestSchemaandTransferProjectOwnershipResponseSchema.
Invite an owner
POST /invitations with {"email":"co.lead@studio.example","role":"owner"}. The responses are the
same as for other roles. The email says “as an owner”. The audit event
management.member.invited has metadata {"actor_role":"owner","project_role":"owner"}.
Accepting an owner invitation creates a membership with role = 'owner' and
project_role = 'owner'. It works only while the developer who sent it is still an owner of the
project. If they were demoted in the meantime, accepting returns the generic
400 invalid_invitation, and the denied management.invitation.accepted event records
reason: "inviter_not_owner". The same check runs again inside the D1 batch that consumes the
invitation.
Migration 0015_project_owner_invitations.sql widens the project_invitations.role CHECK
constraint to allow owner. See docs/operations/d1-schema.md.
Promote a member to owner
PATCH /members/{membershipId} with {"role":"owner"} returns {"member":{...}} with
"role":"owner". Both memberships.role and project_role become owner. The audit event is
management.member.role_updated with project_role: "owner". Demoting an owner works as before,
including the last-owner check.
Transfer ownership
POST /management/tenants/{tenantId}/ownership-transfer
Authorization: Bearer {management token}
Content-Type: application/json
{"membershipId":"mem_0f8c0f0d6a2b4e8c9d0e1f2a3b4c5d6e"}
The caller must be an owner. The target becomes an owner and the caller becomes an admin. Both changes and the audit event commit in one D1 batch, or none of them do.
{
"owner": {
"membershipId": "mem_0f8c0f0d6a2b4e8c9d0e1f2a3b4c5d6e",
"userId": "usr_8c1d2e3f4a5b6c7d8e9f0a1b",
"role": "owner",
"email": "co.lead@studio.example",
"createdAt": "2026-10-05T08:00:00.000Z"
},
"previousOwner": {
"membershipId": "mem_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"userId": "usr_1f2e3d4c5b6a7f8e9d0c1b2a",
"role": "admin",
"email": "lead@studio.example",
"createdAt": "2026-09-01T08:00:00.000Z"
}
}
| Result | Response |
|---|---|
| Transferred | 200 as above |
| Caller is an admin or viewer, or stopped being an owner mid-request | 403 insufficient_role |
| No such member in this project | 404 not_found |
| The target is the caller | 400 invalid_request |
| The target is already an owner | 409 already_owner |
| The target’s role changed while the request ran | 409 membership_changed |
Body is not {"membershipId":"mem_…"} |
400 validation_failed |
| The project is suspended | 409 project_suspended |
To make someone an owner without stepping down, use PATCH /members/{membershipId} instead.
Atomicity and the last owner
The batch has three statements:
- The audit insert. It checks that the caller is still an owner of an active project and that the target still has the role that was read. If not, the insert writes NULL into a NOT NULL column, and D1 rolls back the whole batch.
- The target’s membership becomes
owner. - The caller’s membership becomes
admin, but only while another owner exists. Otherwise the update writes NULL intomemberships.role, and the batch rolls back.
Two concurrent transfers by the same owner therefore commit at most once. The second one finds the caller is no longer an owner and returns 403.
Audit
A committed transfer writes one management.member.ownership_transferred event. target_type is
membership and target_id is the new owner’s membership. The metadata is:
{
"actor_role": "owner",
"previous_project_role": "viewer",
"project_role": "owner",
"previous_owner_membership_id": "mem_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"previous_owner_project_role": "admin"
}
Refused attempts by admins and viewers write a denied event with
reason: "insufficient_role". Audit entries never contain email addresses.
After a committed transfer, the API also publishes two AUTH4D-158 member.changed flow events with
change: "role_changed": one with role: "owner" for the new owner and one with role: "admin"
for the previous owner. Promotion through PATCH and accepted owner invitations publish the usual
single event.
Console
On the Team screen, owners see Owner as a role choice in Invite member and Change role. Each
member who isn’t an owner gets a Transfer ownership action. It opens a confirmation dialog that
names the member and says the owner will become an admin. Nothing is sent until the owner confirms.
API error codes are mapped to console wording; server messages are never shown. Admins and viewers
see none of these controls.
Tests
tests/integration/auth4d-138.members-ownership.test.tscovers the API (owner invitations, promotion, transfer, 403 for admins and viewers, last owner, concurrency, tenant isolation, audit) and BFF forwarding with the same-origin check.tests/e2e/auth4d-138.ownership.spec.ts(port 5238) covers the Team screen, including the confirmation dialog, keyboard focus, axe checks and 320px and 1280px layouts.packages/db/src/owner-invitations-migration.test.tsrehearses migration 0015 on existing data.
Source: docs/api-spec/auth4d-138.md