diff --git a/backend/src/app.ts b/backend/src/app.ts
index 91f8af6..1454ca9 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -3390,7 +3390,7 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks
});
app.put('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
- const parsed = z.object({ role: workspaceRoleSchema.exclude(['owner']) }).safeParse(req.body);
+ const parsed = z.object({ role: workspaceRoleSchema }).safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid flock member role payload', details: parsed.error.flatten() });
@@ -3400,6 +3400,12 @@ app.put('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, req
try {
const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : '';
const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email));
+
+ if (parsed.data.role === 'owner' && !requesterIsBillingOwner) {
+ res.status(403).json({ error: 'Only the billing owner can promote collaborators to owner.' });
+ return;
+ }
+
const member = await updateWorkspaceMemberRole({
memberId: req.params.memberId,
workspaceId: req.auth!.workspace.id,
diff --git a/backend/src/repositories/workspaceRepository.test.ts b/backend/src/repositories/workspaceRepository.test.ts
index fca4660..645c0be 100644
--- a/backend/src/repositories/workspaceRepository.test.ts
+++ b/backend/src/repositories/workspaceRepository.test.ts
@@ -415,6 +415,57 @@ test('updateWorkspaceMemberRole does not let a non-billing owner change the bill
assert.equal(member, null);
});
+test('updateWorkspaceMemberRole lets the billing owner promote a non-owner to owner', async () => {
+ const { calls } = mockDb({
+ rowCount: 1,
+ rows: [
+ {
+ id: 'member-1',
+ workspace_id: 42,
+ user_id: 'user-2',
+ invite_email: 'helper@example.com',
+ name: 'Helper',
+ role: 'owner',
+ accepted_at: '2026-04-14T00:00:00.000Z',
+ created_at: '2026-04-14T00:00:00.000Z',
+ },
+ ],
+ });
+
+ const member = await updateWorkspaceMemberRole({
+ memberId: 'member-1',
+ workspaceId: 42,
+ role: 'owner',
+ requesterMemberId: 'billing-owner',
+ requesterIsBillingOwner: true,
+ requesterRole: 'owner',
+ billingEmail: 'billing@example.com',
+ });
+
+ assert.equal(member?.role, 'owner');
+ assert.deepEqual(calls[0].params, ['member-1', 42, 'owner', true, 'billing-owner', 'billing@example.com', 'owner']);
+ assert.match(calls[0].text, /\$3 <> 'owner'/);
+});
+
+test('updateWorkspaceMemberRole does not let a non-billing owner promote a member to owner', async () => {
+ mockDb({
+ rowCount: 0,
+ rows: [],
+ });
+
+ const member = await updateWorkspaceMemberRole({
+ memberId: 'member-1',
+ workspaceId: 42,
+ role: 'owner',
+ requesterMemberId: 'non-billing-owner',
+ requesterIsBillingOwner: false,
+ requesterRole: 'owner',
+ billingEmail: 'billing@example.com',
+ });
+
+ assert.equal(member, null);
+});
+
test('deleteWorkspaceMember removes non-owner members without billing owner access', async () => {
const { calls } = mockDb({
rowCount: 1,
diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts
index 565bcbd..e817262 100644
--- a/backend/src/repositories/workspaceRepository.ts
+++ b/backend/src/repositories/workspaceRepository.ts
@@ -415,6 +415,10 @@ export const updateWorkspaceMemberRole = async ({
SET role = $3
WHERE id = $1
AND workspace_id = $2
+ AND (
+ $3 <> 'owner'
+ OR $4 = TRUE
+ )
AND (
role <> 'owner'
OR (
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d0ef1b0..b8b6dec 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -7925,6 +7925,7 @@ function App() {
})
}
>
+ {isBillingOwner ? : null}
@@ -7949,6 +7950,7 @@ function App() {
activeMembership?.role === 'owner' &&
member.id !== activeMembership.id &&
(isBillingOwner || !memberIsBillingOwner);
+ const canPromoteToOwner = member.role !== 'owner' && isBillingOwner && member.id !== activeMembership?.id;
const canRemoveMember = member.role !== 'owner' || canRemoveOwner;
return (
@@ -7969,7 +7971,7 @@ function App() {
removingWorkspaceMemberId === member.id
}
>
- {member.role === 'owner' ? : null}
+ {member.role === 'owner' || canPromoteToOwner ? : null}