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.role and UpdateProjectMemberRoleRequestSchema.role now take any ProjectRoleSchema value (owner, admin or viewer). InvitableProjectRoleSchema is unchanged and still lists the roles admins and owners could invite before this change.
  • New TransferProjectOwnershipRequestSchema and TransferProjectOwnershipResponseSchema.

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:

  1. 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.
  2. The target’s membership becomes owner.
  3. The caller’s membership becomes admin, but only while another owner exists. Otherwise the update writes NULL into memberships.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.ts covers 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.ts rehearses migration 0015 on existing data.

Source: docs/api-spec/auth4d-138.md