diff --git a/src/langbot/pkg/api/http/authz.py b/src/langbot/pkg/api/http/authz.py
index 225e30aa8..7bfc8dbe3 100644
--- a/src/langbot/pkg/api/http/authz.py
+++ b/src/langbot/pkg/api/http/authz.py
@@ -19,7 +19,6 @@ class Permission(enum.StrEnum):
WORKSPACE_VIEW = 'workspace.view'
WORKSPACE_UPDATE = 'workspace.update'
WORKSPACE_DELETE = 'workspace.delete'
- OWNER_TRANSFER = 'owner.transfer'
MEMBER_VIEW = 'member.view'
MEMBER_INVITE = 'member.invite'
MEMBER_UPDATE_ROLE = 'member.update_role'
@@ -49,7 +48,6 @@ _ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
if permission
not in {
Permission.WORKSPACE_DELETE,
- Permission.OWNER_TRANSFER,
Permission.BILLING_LINK_MANAGE,
}
),
diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py
index 0917c6c6d..6459ecec7 100644
--- a/src/langbot/pkg/api/http/controller/group.py
+++ b/src/langbot/pkg/api/http/controller/group.py
@@ -62,7 +62,6 @@ class AuthType(enum.Enum):
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
{
- Permission.OWNER_TRANSFER.value,
Permission.MEMBER_VIEW.value,
Permission.MEMBER_INVITE.value,
Permission.MEMBER_UPDATE_ROLE.value,
diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py
index 49b59906b..e8e450911 100644
--- a/src/langbot/pkg/api/http/controller/groups/user.py
+++ b/src/langbot/pkg/api/http/controller/groups/user.py
@@ -291,11 +291,19 @@ class UserRouterGroup(group.RouterGroup):
# Workspace owner is already bound even when this Core has no local OAuth
# token row (model billing uses the owner's control-plane API key).
owner_space_bound = cloud_mode or owner_has_local_space_credentials
- credits = (
- await self.ap.space_service.get_credits(owner.user)
- if owner is not None and owner.space_account_uuid
- else None
- )
+ if cloud_mode:
+ catalog_service = getattr(self.ap, 'cloud_model_catalog_service', None)
+ credits = (
+ catalog_service.get_workspace_credits(access.workspace.uuid)
+ if catalog_service is not None
+ else None
+ )
+ else:
+ credits = (
+ await self.ap.space_service.get_credits(owner.user)
+ if owner is not None and owner.space_account_uuid
+ else None
+ )
return self.success(
data={
'credits': credits,
diff --git a/src/langbot/pkg/cloud/model_catalog.py b/src/langbot/pkg/cloud/model_catalog.py
index a76376505..056159e0a 100644
--- a/src/langbot/pkg/cloud/model_catalog.py
+++ b/src/langbot/pkg/cloud/model_catalog.py
@@ -53,6 +53,7 @@ class CloudWorkspaceModelBilling(BaseModel):
workspace_uuid: str = Field(min_length=36, max_length=36)
owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36)
api_key: SecretStr | None = None
+ credits: int | None = None
@field_validator('workspace_uuid')
@classmethod
@@ -149,6 +150,11 @@ class CloudModelCatalogSyncService:
# convergence marker so a failed runtime reload is retried even when the
# following database reconciliation is a no-op.
self._runtime_reload_pending = False
+ self._workspace_credits: dict[str, int | None] = {}
+
+ def get_workspace_credits(self, workspace_uuid: str) -> int | None:
+ """Return the latest signed owner-credit projection for a Workspace."""
+ return self._workspace_credits.get(str(uuid.UUID(workspace_uuid)))
async def initialize(self) -> None:
await self.sync_once(reload_runtime=False)
@@ -198,6 +204,7 @@ class CloudModelCatalogSyncService:
self._runtime_reload_pending = True
for key in ('created', 'updated', 'deleted'):
summary[key] += counts[key]
+ self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits
except Exception as exc:
sync_error = exc
finally:
diff --git a/src/langbot/pkg/entity/persistence/workspace.py b/src/langbot/pkg/entity/persistence/workspace.py
index ca3743d4b..822c8e9d4 100644
--- a/src/langbot/pkg/entity/persistence/workspace.py
+++ b/src/langbot/pkg/entity/persistence/workspace.py
@@ -163,6 +163,13 @@ class WorkspaceMembership(Base):
__table_args__ = (
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
+ sqlalchemy.Index(
+ 'uq_workspace_memberships_one_active_owner',
+ 'workspace_uuid',
+ unique=True,
+ sqlite_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
+ postgresql_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
+ ),
sqlalchemy.CheckConstraint(
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
name='ck_workspace_memberships_role',
diff --git a/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py b/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py
new file mode 100644
index 000000000..55e4e08f2
--- /dev/null
+++ b/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py
@@ -0,0 +1,80 @@
+"""enforce one active owner per Workspace
+
+Revision ID: 0019_single_workspace_owner
+Revises: 0018_merge_launch_replay
+Create Date: 2026-08-02
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = '0019_single_workspace_owner'
+down_revision = '0018_merge_launch_replay'
+branch_labels = None
+depends_on = None
+
+_INDEX_NAME = 'uq_workspace_memberships_one_active_owner'
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ if 'workspace_memberships' not in inspector.get_table_names():
+ return
+
+ # Ownership transfer used to promote a second member without demoting the
+ # original owner. Preserve the Workspace creator where possible and demote
+ # every historical extra owner before installing the database invariant.
+ op.execute(
+ sa.text(
+ """
+ WITH ranked_owners AS (
+ SELECT membership.uuid,
+ ROW_NUMBER() OVER (
+ PARTITION BY membership.workspace_uuid
+ ORDER BY
+ CASE
+ WHEN membership.account_uuid = workspace.created_by_account_uuid THEN 0
+ ELSE 1
+ END,
+ COALESCE(membership.joined_at, membership.created_at),
+ membership.uuid
+ ) AS owner_rank
+ FROM workspace_memberships AS membership
+ JOIN workspaces AS workspace
+ ON workspace.uuid = membership.workspace_uuid
+ WHERE membership.role = 'owner'
+ AND membership.status = 'active'
+ )
+ UPDATE workspace_memberships
+ SET role = 'admin'
+ WHERE uuid IN (
+ SELECT uuid
+ FROM ranked_owners
+ WHERE owner_rank > 1
+ )
+ """
+ )
+ )
+ # Fresh installations may already have this index because SQLAlchemy
+ # metadata is created before Alembic advances the revision marker.
+ op.execute(
+ sa.text(
+ 'CREATE UNIQUE INDEX IF NOT EXISTS '
+ 'uq_workspace_memberships_one_active_owner '
+ 'ON workspace_memberships (workspace_uuid) '
+ "WHERE role = 'owner' AND status = 'active'"
+ )
+ )
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ if 'workspace_memberships' not in inspector.get_table_names():
+ return
+ index_names = {index['name'] for index in inspector.get_indexes('workspace_memberships')}
+ if _INDEX_NAME in index_names:
+ op.drop_index(_INDEX_NAME, table_name='workspace_memberships')
diff --git a/src/langbot/pkg/workspace/collaboration.py b/src/langbot/pkg/workspace/collaboration.py
index bcebe47c4..3ffbb6999 100644
--- a/src/langbot/pkg/workspace/collaboration.py
+++ b/src/langbot/pkg/workspace/collaboration.py
@@ -606,6 +606,8 @@ class WorkspaceCollaborationService:
) -> WorkspaceMembership:
if role not in {item.value for item in MembershipRole}:
raise MembershipPermissionError('Unknown Workspace role')
+ if role == MembershipRole.OWNER.value:
+ raise MembershipPermissionError('Workspace ownership cannot be transferred')
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
await self._require_active_workspace(active_session, workspace_uuid)
@@ -617,8 +619,8 @@ class WorkspaceCollaborationService:
target_account_uuid,
)
self._require_can_manage_target(persisted_actor, target, new_role=role)
- if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value:
- await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
+ if target.role == MembershipRole.OWNER.value:
+ raise LastOwnerError('The Workspace owner cannot be removed or demoted')
target.role = role
await active_session.flush()
return target
@@ -644,7 +646,7 @@ class WorkspaceCollaborationService:
)
self._require_can_manage_target(persisted_actor, target)
if target.role == MembershipRole.OWNER.value:
- await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
+ raise LastOwnerError('The Workspace owner cannot be removed or demoted')
target.status = MembershipStatus.REMOVED.value
await active_session.flush()
return target
@@ -751,26 +753,6 @@ class WorkspaceCollaborationService:
raise WorkspaceNotFoundError('Workspace not found')
return persisted_actor
- async def _require_another_owner(
- self,
- session: AsyncSession,
- workspace_uuid: str,
- excluded_account_uuid: str,
- ) -> None:
- owners = (
- await session.scalars(
- sqlalchemy.select(WorkspaceMembership)
- .where(
- WorkspaceMembership.workspace_uuid == workspace_uuid,
- WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
- WorkspaceMembership.role == MembershipRole.OWNER.value,
- )
- .with_for_update()
- )
- ).all()
- if not any(owner.account_uuid != excluded_account_uuid for owner in owners):
- raise LastOwnerError('The last Workspace owner cannot be removed or demoted')
-
def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
raise WorkspaceNotFoundError('Workspace not found')
diff --git a/tests/integration/api/test_support_admin_launch.py b/tests/integration/api/test_support_admin_launch.py
index 94647a7f1..e71cb4374 100644
--- a/tests/integration/api/test_support_admin_launch.py
+++ b/tests/integration/api/test_support_admin_launch.py
@@ -333,7 +333,6 @@ async def test_support_admin_request_context_has_actor_owner_and_no_membership(s
assert Permission.RESOURCE_MANAGE.value in permissions
assert not permissions.intersection(
{
- Permission.OWNER_TRANSFER.value,
Permission.MEMBER_VIEW.value,
Permission.MEMBER_INVITE.value,
Permission.MEMBER_UPDATE_ROLE.value,
diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py
index 35e048053..16c86156d 100644
--- a/tests/integration/api/test_user_space_oauth.py
+++ b/tests/integration/api/test_user_space_oauth.py
@@ -289,6 +289,9 @@ async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oau
application.deployment.mode = 'cloud'
application.user_service.get_workspace_owner = AsyncMock(return_value=None)
application.space_service.get_credits = AsyncMock()
+ application.cloud_model_catalog_service = SimpleNamespace(
+ get_workspace_credits=lambda workspace_uuid: 25000 if workspace_uuid == WORKSPACE_UUID else None
+ )
response = await client.get(
'/api/v1/user/space-credits',
@@ -298,7 +301,7 @@ async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oau
assert response.status_code == 200
assert payload['data'] == {
- 'credits': None,
+ 'credits': 25000,
'owner_space_bound': True,
'is_workspace_owner': True,
}
diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py
index 7547f4800..d3a70a56d 100644
--- a/tests/integration/api/test_workspaces.py
+++ b/tests/integration/api/test_workspaces.py
@@ -188,6 +188,7 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
workspace_uuid = current['workspace']['uuid']
assert current['membership']['role'] == 'owner'
assert 'member.invite' in current['permissions']
+ assert 'owner.transfer' not in current['permissions']
invite_response = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations',
@@ -263,6 +264,14 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
assert member_current['membership']['role'] == 'viewer'
assert 'member.invite' not in member_current['permissions']
+ transfer_response = await client.patch(
+ f'/api/v1/workspaces/{workspace_uuid}/members/{member_current["membership"]["account_uuid"]}',
+ headers=_auth(owner_token, workspace_uuid),
+ json={'role': 'owner'},
+ )
+ assert transfer_response.status_code == 403
+ assert (await transfer_response.get_json())['code'] == 'permission_denied'
+
forbidden_invite = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations',
headers=_auth(member_token, workspace_uuid),
diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py
index 3447c9320..933d6fcd4 100644
--- a/tests/integration/persistence/test_migrations.py
+++ b/tests/integration/persistence/test_migrations.py
@@ -105,7 +105,7 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
- assert _get_script_head() == '0018_merge_launch_replay'
+ assert _get_script_head() == '0019_single_workspace_owner'
@pytest.mark.asyncio
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
diff --git a/tests/integration/persistence/test_single_owner_migration.py b/tests/integration/persistence/test_single_owner_migration.py
new file mode 100644
index 000000000..249759d72
--- /dev/null
+++ b/tests/integration/persistence/test_single_owner_migration.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.user import User
+from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceMembership
+from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
+
+
+@pytest.mark.asyncio
+async def test_single_owner_migration_demotes_historical_extra_owner_and_installs_unique_index(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "single-owner.db"}')
+ try:
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+ await connection.execute(sa.text('DROP INDEX uq_workspace_memberships_one_active_owner'))
+
+ session_factory = async_sessionmaker(engine, expire_on_commit=False)
+ workspace_uuid = '00000000-0000-4000-8000-000000000001'
+ creator_uuid = '00000000-0000-4000-8000-000000000010'
+ promoted_uuid = '00000000-0000-4000-8000-000000000020'
+ async with session_factory() as session:
+ session.add_all(
+ [
+ User(
+ uuid=creator_uuid,
+ user='creator@example.test',
+ normalized_email='creator@example.test',
+ password='hash',
+ account_type='local',
+ ),
+ User(
+ uuid=promoted_uuid,
+ user='promoted@example.test',
+ normalized_email='promoted@example.test',
+ password='hash',
+ account_type='local',
+ ),
+ Workspace(
+ uuid=workspace_uuid,
+ instance_uuid='instance-test',
+ name='Workspace',
+ slug='workspace',
+ type='team',
+ status='active',
+ source='local',
+ created_by_account_uuid=creator_uuid,
+ ),
+ WorkspaceMembership(
+ uuid='00000000-0000-4000-8000-000000000100',
+ workspace_uuid=workspace_uuid,
+ account_uuid=creator_uuid,
+ role='owner',
+ status='active',
+ ),
+ WorkspaceMembership(
+ uuid='00000000-0000-4000-8000-000000000200',
+ workspace_uuid=workspace_uuid,
+ account_uuid=promoted_uuid,
+ role='owner',
+ status='active',
+ ),
+ ]
+ )
+ await session.commit()
+
+ await run_alembic_stamp(engine, '0018_merge_launch_replay')
+ await run_alembic_upgrade(engine, 'head')
+
+ async with engine.connect() as connection:
+ roles = dict(
+ (
+ await connection.execute(
+ sa.text(
+ 'SELECT account_uuid, role FROM workspace_memberships '
+ 'WHERE workspace_uuid = :workspace_uuid ORDER BY account_uuid'
+ ),
+ {'workspace_uuid': workspace_uuid},
+ )
+ ).all()
+ )
+ assert roles == {creator_uuid: 'owner', promoted_uuid: 'admin'}
+ indexes = await connection.run_sync(
+ lambda sync_connection: {
+ index['name'] for index in sa.inspect(sync_connection).get_indexes('workspace_memberships')
+ }
+ )
+ assert 'uq_workspace_memberships_one_active_owner' in indexes
+
+ with pytest.raises(sa.exc.IntegrityError):
+ async with engine.begin() as connection:
+ await connection.execute(
+ sa.text("UPDATE workspace_memberships SET role = 'owner' WHERE account_uuid = :account_uuid"),
+ {'account_uuid': promoted_uuid},
+ )
+ finally:
+ await engine.dispose()
diff --git a/tests/unit_tests/api/http/test_authz.py b/tests/unit_tests/api/http/test_authz.py
index 705891a16..620700bc5 100644
--- a/tests/unit_tests/api/http/test_authz.py
+++ b/tests/unit_tests/api/http/test_authz.py
@@ -27,10 +27,9 @@ def test_owner_has_every_fixed_permission():
assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission)
-def test_admin_cannot_transfer_owner_delete_workspace_or_link_billing():
+def test_admin_cannot_delete_workspace_or_link_billing():
ctx = _context(authz.WorkspaceRole.ADMIN)
- assert not authz.has_permission(ctx, authz.Permission.OWNER_TRANSFER)
assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE)
assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
diff --git a/tests/unit_tests/cloud/test_model_catalog.py b/tests/unit_tests/cloud/test_model_catalog.py
index 6f4b0a275..5d1de001f 100644
--- a/tests/unit_tests/cloud/test_model_catalog.py
+++ b/tests/unit_tests/cloud/test_model_catalog.py
@@ -73,11 +73,13 @@ def _snapshot(
'workspace_uuid': WORKSPACE_A,
'owner_account_uuid': OWNER_A,
'api_key': key_a,
+ 'credits': 25000,
},
{
'workspace_uuid': WORKSPACE_B,
'owner_account_uuid': OWNER_B,
'api_key': 'owner-b-key',
+ 'credits': 5000,
},
],
}
@@ -160,6 +162,8 @@ async def test_catalog_reconciles_every_workspace_idempotently_and_tracks_owner_
first = await service.sync_once()
assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0}
assert reload_counter.calls == 1
+ assert service.get_workspace_credits(WORKSPACE_A) == 25000
+ assert service.get_workspace_credits(WORKSPACE_B) == 5000
async with engine.connect() as connection:
providers = (
@@ -306,6 +310,8 @@ async def test_partial_workspace_failure_reloads_already_committed_changes() ->
with pytest.raises(RuntimeError, match='second Workspace failed'):
await service.sync_once()
+ assert service.get_workspace_credits(WORKSPACE_A) == 25000
+ assert service.get_workspace_credits(WORKSPACE_B) is None
assert reload_counter.calls == 1
diff --git a/tests/unit_tests/workspace/test_workspace_collaboration.py b/tests/unit_tests/workspace/test_workspace_collaboration.py
index a9a79c42b..77974f4ff 100644
--- a/tests/unit_tests/workspace/test_workspace_collaboration.py
+++ b/tests/unit_tests/workspace/test_workspace_collaboration.py
@@ -203,20 +203,21 @@ async def test_last_owner_cannot_be_demoted(collaboration_context):
second_membership,
)
- promoted = await service.update_member_role(
- workspace.uuid,
- second.uuid,
- 'owner',
- owner_membership,
- )
- assert promoted.role == 'owner'
- demoted = await service.update_member_role(
- workspace.uuid,
- owner_membership.account_uuid,
- 'admin',
- owner_membership,
- )
- assert demoted.role == 'admin'
+ with pytest.raises(MembershipPermissionError, match='cannot be transferred'):
+ await service.update_member_role(
+ workspace.uuid,
+ second.uuid,
+ 'owner',
+ owner_membership,
+ )
+
+ with pytest.raises(LastOwnerError):
+ await service.update_member_role(
+ workspace.uuid,
+ owner_membership.account_uuid,
+ 'admin',
+ owner_membership,
+ )
async def test_workspace_selector_requires_membership(collaboration_context):
diff --git a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx
index b771d77bc..b4dd71866 100644
--- a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx
+++ b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx
@@ -218,20 +218,22 @@ export default function ProviderCard({
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
-
+ {isWorkspaceOwner && (
+
+ )}
)}
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
diff --git a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx
index ebe184040..2469c5194 100644
--- a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx
+++ b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx
@@ -79,7 +79,6 @@ export default function WorkspaceSettingsPanel({
const canInvite = permissions.has('member.invite');
const canUpdateMembers = permissions.has('member.update_role');
const canRemoveMembers = permissions.has('member.remove');
- const canTransferOwner = permissions.has('owner.transfer');
const cloudPortalURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
: '';
@@ -342,11 +341,6 @@ export default function WorkspaceSettingsPanel({
{t(`workspace.roles.${role}`)}
))}
- {canTransferOwner && (
-
- {t('workspace.transferOwnership')}
-
- )}
)}
diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts
index 23e2c0a8f..98ad9f25a 100644
--- a/web/tests/e2e/fixtures/langbot-api.ts
+++ b/web/tests/e2e/fixtures/langbot-api.ts
@@ -169,7 +169,6 @@ export function makeWorkspaceEntry(
'member.remove',
'member.update_role',
'member.view',
- 'owner.transfer',
'provider_secret.manage',
'resource.manage',
'resource.view',
diff --git a/web/tests/unit/oss-account-space-billing.test.mjs b/web/tests/unit/oss-account-space-billing.test.mjs
index 963b78ba8..e49cf43cb 100644
--- a/web/tests/unit/oss-account-space-billing.test.mjs
+++ b/web/tests/unit/oss-account-space-billing.test.mjs
@@ -46,4 +46,14 @@ test('provider card represents owner and member owner-bound states explicitly',
assert.match(source, /ownerSpaceBound/);
assert.match(source, /models\.ownerMustBindSpace/);
assert.match(source, /models\.usesOwnerSpaceBilling/);
+ assert.match(source, /isWorkspaceOwner && \(\s*