Repository: specklesystems/speckle-server
Pull request #4117: feat(server): prevent viewer seat from becoming project contributor/owner + extra fixes
Author: login: fabis94
Created: 2025-03-05T14:05:03Z | Merged: 2025-03-06T11:26:16Z

<!---

Provide a short summary in the Title above. Examples of good PR titles:

* "Feature: adds metrics to component"

* "Fix: resolves duplication in comment thread"

* "Update: apollo v2.34.0"

-->

## Description & motivation

<!---

Describe your changes, and why you're making them.  What benefit will this have to others?

Is this linked to an open Github issue, a thread in Speckle community,
or another pull request? Link it here.

If it is related to a Github issue, and resolves it, please link to the issue number, e.g.:
Fixes #85, Fixes #22, Fixes username/repo#123
Connects #123

-->

## Changes:

<!---

- Item 1
- Item 2

-->

## To-do before merge:

<!---

(Optional -- remove this section if not needed)

Include any notes about things that need to happen before this PR is merged, e.g.:

- [ ] Change the base branch

- [ ] Ensure PR #56 is merged

-->

## Screenshots:

<!---

Include a screenshot the before and after.  This can be a screenshot of a plugin, web frontend, or output in a terminal.

-->

## Validation of changes:

<!---

Describe what tests have been added or amended, and why these demonstrate it works and will prevent this feature being accidentally broken by future changes.

-->

## Checklist:

<!---

This checklist is mostly useful as a reminder of small things that can easily be

forgotten – it is meant as a helpful tool rather than hoops to jump through.

Put an `x` between the square brackets, e.g. [x], for all the items that apply,

make notes next to any that haven't been addressed, and remove any items that are not relevant to this PR.

-->

- [ ] My pull request follows the guidelines in the [Contributing guide](https://github.com/specklesystems/speckle-server/blob/main/CONTRIBUTING.md)?
- [ ] My pull request does not duplicate any other open [Pull Requests](../../pulls) for the same update/change?
- [ ] My commits are related to the pull request and do not amend unrelated code or documentation.
- [ ] My code follows a similar style to existing code.
- [ ] I have added appropriate tests.
- [ ] I have updated or added relevant documentation.

## References

<!---

(Optional -- remove this section if not needed )

Include **important** links regarding the implementation of this PR.

This usually includes a RFC or an aggregation of issues and/or individual conversations

that helped put this solution together. This helps ensure we retain and share knowledge

regarding the implementation, and may help others understand motivation and design decisions etc..

-->



Diff:
diff --git a/packages/frontend-2/components/settings/workspaces/billing/Page.vue b/packages/frontend-2/components/settings/workspaces/billing/Page.vue
index 931158b376a..8643e9bbb16 100644
--- a/packages/frontend-2/components/settings/workspaces/billing/Page.vue
+++ b/packages/frontend-2/components/settings/workspaces/billing/Page.vue
@@ -286,7 +286,13 @@ const isUpgradeDialogOpen = ref(false)
 const seatPrices = computed(() => ({
   [WorkspacePlans.Starter]: prices.value?.[WorkspacePlans.Starter],
   [WorkspacePlans.Plus]: prices.value?.[WorkspacePlans.Plus],
-  [WorkspacePlans.Business]: prices.value?.[WorkspacePlans.Business]
+  [WorkspacePlans.Business]: prices.value?.[WorkspacePlans.Business],
+  ...(isWorkspaceNewPlansEnabled.value
+    ? {
+        [WorkspacePlans.Team]: prices.value?.[WorkspacePlans.Team],
+        [WorkspacePlans.Pro]: prices.value?.[WorkspacePlans.Pro]
+      }
+    : {})
 }))
 const workspace = computed(() => workspaceResult.value?.workspaceBySlug)
 const currentPlan = computed(() => workspace.value?.plan)
diff --git a/packages/frontend-2/lib/core/configs/apollo.ts b/packages/frontend-2/lib/core/configs/apollo.ts
index 0b6d344ce48..0bdf3fb9cef 100644
--- a/packages/frontend-2/lib/core/configs/apollo.ts
+++ b/packages/frontend-2/lib/core/configs/apollo.ts
@@ -300,6 +300,9 @@ function createCache(): InMemoryCache {
           projects: {
             keyArgs: ['filter', 'limit'],
             merge: buildAbstractCollectionMergeFunction('ProjectCollection')
+          },
+          subscription: {
+            merge: mergeAsObjectsFunction
           }
         }
       }
diff --git a/packages/server/bootstrap.js b/packages/server/bootstrap.js
index ead9317c7ab..4d83ef50fa2 100644
--- a/packages/server/bootstrap.js
+++ b/packages/server/bootstrap.js
@@ -22,7 +22,8 @@ const {
   isTestEnv,
   isApolloMonitoringEnabled,
   getApolloServerVersion,
-  getServerVersion
+  getServerVersion,
+  isDevEnv
 } = require('@/modules/shared/helpers/envHelper')
 const { logger } = require('@/observability/logging')
 
@@ -43,6 +44,16 @@ if (isTestEnv()) {
   }
 }
 
+// Custom inspector init, when debugging doesn't work any other way
+// (e.g. due to various child processes capturing the --inspect flag)
+const startDebugger = process.env.START_DEBUGGER
+if ((isTestEnv() || isDevEnv()) && startDebugger) {
+  const inspector = require('node:inspector')
+  if (!inspector.url()) {
+    inspector.open(undefined, undefined, true)
+  }
+}
+
 dotenv.config({ path: `${packageRoot}/.env` })
 
 // knex is a singleton controlled by module so can't wait til app init
diff --git a/packages/server/modules/core/domain/projects/events.ts b/packages/server/modules/core/domain/projects/events.ts
index 423e6b6e64a..dd2012e890f 100644
--- a/packages/server/modules/core/domain/projects/events.ts
+++ b/packages/server/modules/core/domain/projects/events.ts
@@ -14,6 +14,7 @@ export const ProjectEvents = {
   Updated: `${projectEventsNamespace}.updated`,
   Deleted: `${projectEventsNamespace}.deleted`,
   Cloned: `${projectEventsNamespace}.cloned`,
+  PermissionsBeingAdded: `${projectEventsNamespace}.permissionsBeingAdded`,
   PermissionsAdded: `${projectEventsNamespace}.permissionsAdded`,
   PermissionsRevoked: `${projectEventsNamespace}.permissionsRevoked`
 } as const
@@ -40,6 +41,12 @@ export type ProjectEventsPayloads = {
     sourceProject: Project
     newProject: Project
   }
+  [ProjectEvents.PermissionsBeingAdded]: {
+    activityUserId: string
+    targetUserId: string
+    role: StreamRoles
+    projectId: string
+  }
   [ProjectEvents.PermissionsAdded]: {
     activityUserId: string
     targetUserId: string
diff --git a/packages/server/modules/core/services/projects.ts b/packages/server/modules/core/services/projects.ts
index ce3c37385f1..21a40c06ffe 100644
--- a/packages/server/modules/core/services/projects.ts
+++ b/packages/server/modules/core/services/projects.ts
@@ -12,6 +12,7 @@ import {
 import { Project } from '@/modules/core/domain/streams/types'
 import { RegionalProjectCreationError } from '@/modules/core/errors/projects'
 import { StreamNotFoundError } from '@/modules/core/errors/stream'
+import { isTestEnv } from '@/modules/shared/helpers/envHelper'
 import { EventBusEmit } from '@/modules/shared/services/eventBus'
 import { retry } from '@lifeomic/attempt'
 import { Roles } from '@speckle/shared'
@@ -61,13 +62,15 @@ export const createNewProjectFactory =
             const replicatedProject = await getProject({ projectId })
             if (!replicatedProject) throw new StreamNotFoundError()
           },
-          { maxAttempts: 10 }
+          { maxAttempts: 10, delay: isTestEnv() ? 1000 : undefined }
         )
       } catch (err) {
         if (err instanceof StreamNotFoundError) {
           // delete from region
           await deleteProject({ projectId })
-          throw new RegionalProjectCreationError()
+          throw new RegionalProjectCreationError(undefined, {
+            info: { projectId, regionKey }
+          })
         }
         // else throw as is
         throw err
diff --git a/packages/server/modules/core/services/streams/access.ts b/packages/server/modules/core/services/streams/access.ts
index 24e7bcfa1e7..534d84b260e 100644
--- a/packages/server/modules/core/services/streams/access.ts
+++ b/packages/server/modules/core/services/streams/access.ts
@@ -177,6 +177,17 @@ export const addOrUpdateStreamCollaboratorFactory =
         throw new BadRequestError('Server guests cannot own streams')
     }
 
+    // Allows for dynamic extra validation
+    await deps.emitEvent({
+      eventName: ProjectEvents.PermissionsBeingAdded,
+      payload: {
+        activityUserId: addedById,
+        targetUserId: userId,
+        role: role as StreamRoles,
+        projectId: streamId
+      }
+    })
+
     const stream = (await deps.grantStreamPermissions({
       streamId,
       userId,
diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts
index 722d2089c76..1e67167f74e 100644
--- a/packages/server/modules/gatekeeper/domain/billing.ts
+++ b/packages/server/modules/gatekeeper/domain/billing.ts
@@ -173,7 +173,12 @@ export type ReconcileSubscriptionData = (args: {
   applyProrotation: boolean
 }) => Promise<void>
 
-export type WorkspaceSeatType = 'viewer' | 'editor'
+export const WorkspaceSeatType = <const>{
+  Viewer: 'viewer',
+  Editor: 'editor'
+}
+export type WorkspaceSeatType =
+  (typeof WorkspaceSeatType)[keyof typeof WorkspaceSeatType]
 
 export type WorkspaceSeat = {
   workspaceId: string
diff --git a/packages/server/modules/gatekeeper/domain/operations.ts b/packages/server/modules/gatekeeper/domain/operations.ts
index c6ee84fcdad..26f44a47342 100644
--- a/packages/server/modules/gatekeeper/domain/operations.ts
+++ b/packages/server/modules/gatekeeper/domain/operations.ts
@@ -2,6 +2,7 @@ import { WorkspaceSeat } from '@/modules/gatekeeper/domain/billing'
 import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
 import { Workspace } from '@/modules/workspacesCore/domain/types'
 import {
+  Optional,
   WorkspacePlanFeatures,
   WorkspacePlans,
   WorkspacePlanStatuses
@@ -35,3 +36,15 @@ export type GetWorkspacePlanByProjectId = ({
 export type CreateWorkspaceSeat = (
   args: Pick<WorkspaceSeat, 'workspaceId' | 'userId' | 'type'>
 ) => Promise<void>
+
+export type GetWorkspaceUserSeats = (params: {
+  workspaceId: string
+  userIds: string[]
+}) => Promise<{
+  [userId: string]: WorkspaceSeat
+}>
+
+export type GetWorkspaceUserSeat = (params: {
+  workspaceId: string
+  userId: string
+}) => Promise<Optional<WorkspaceSeat>>
diff --git a/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts b/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts
index d22b2233f54..e80304f0125 100644
--- a/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts
+++ b/packages/server/modules/gatekeeper/repositories/workspaceSeat.ts
@@ -1,9 +1,22 @@
+import { buildTableHelper } from '@/modules/core/dbSchema'
 import { WorkspaceSeat } from '@/modules/gatekeeper/domain/billing'
-import { CreateWorkspaceSeat } from '@/modules/gatekeeper/domain/operations'
+import {
+  CreateWorkspaceSeat,
+  GetWorkspaceUserSeat,
+  GetWorkspaceUserSeats
+} from '@/modules/gatekeeper/domain/operations'
 import { Knex } from 'knex'
 
+const WorkspaceSeats = buildTableHelper('workspace_seats', [
+  'workspaceId',
+  'userId',
+  'type',
+  'createdAt',
+  'updatedAt'
+])
+
 const tables = {
-  workspaceSeats: (db: Knex) => db<WorkspaceSeat>('workspace_seats')
+  workspaceSeats: (db: Knex) => db<WorkspaceSeat>(WorkspaceSeats.name)
 }
 
 export const countSeatsByTypeInWorkspaceFactory =
@@ -32,3 +45,26 @@ export const createWorkspaceSeatFactory =
       .onConflict(['workspaceId', 'userId'])
       .merge()
   }
+
+export const getWorkspaceUserSeatsFactory =
+  ({ db }: { db: Knex }): GetWorkspaceUserSeats =>
+  async ({ workspaceId, userIds }) => {
+    const seats = await tables
+      .workspaceSeats(db)
+      .where(WorkspaceSeats.col.workspaceId, workspaceId)
+      .whereIn(WorkspaceSeats.col.userId, userIds)
+    return seats.reduce((acc, seat) => {
+      acc[seat.userId] = seat
+      return acc
+    }, {} as { [userId: string]: WorkspaceSeat })
+  }
+
+export const getWorkspaceUserSeatFactory =
+  ({ db }: { db: Knex }): GetWorkspaceUserSeat =>
+  async ({ workspaceId, userId }) => {
+    const seats = await getWorkspaceUserSeatsFactory({ db })({
+      workspaceId,
+      userIds: [userId]
+    })
+    return seats[userId]
+  }
diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts
index a1b862db6f6..506e5131e00 100644
--- a/packages/server/modules/workspaces/events/eventListener.ts
+++ b/packages/server/modules/workspaces/events/eventListener.ts
@@ -26,7 +26,12 @@ import { logger, moduleLogger } from '@/observability/logging'
 import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management'
 import { EventPayload, getEventBus } from '@/modules/shared/services/eventBus'
 import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
-import { Roles, throwUncoveredError, WorkspaceRoles } from '@speckle/shared'
+import {
+  PaidWorkspacePlansNew,
+  Roles,
+  throwUncoveredError,
+  WorkspaceRoles
+} from '@speckle/shared'
 import {
   DeleteProjectRole,
   UpsertProjectRole
@@ -61,7 +66,11 @@ import {
   getUserSsoSessionFactory,
   getWorkspaceSsoProviderRecordFactory
 } from '@/modules/workspaces/repositories/sso'
-import { WorkspacesNotAuthorizedError } from '@/modules/workspaces/errors/workspace'
+import {
+  WorkspaceAdminError,
+  WorkspaceInvalidRoleError,
+  WorkspacesNotAuthorizedError
+} from '@/modules/workspaces/errors/workspace'
 import { publish, WorkspaceSubscriptions } from '@/modules/shared/utils/subscriptions'
 import { isWorkspaceResourceTarget } from '@/modules/workspaces/services/invites'
 import {
@@ -85,7 +94,11 @@ import {
 } from '@/modules/gatekeeper/repositories/billing'
 import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
 import { assignWorkspaceSeatFactory } from '@/modules/workspaces/services/workspaceSeat'
-import { createWorkspaceSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
+import {
+  createWorkspaceSeatFactory,
+  getWorkspaceUserSeatFactory
+} from '@/modules/gatekeeper/repositories/workspaceSeat'
+import { GetWorkspaceUserSeat } from '@/modules/gatekeeper/domain/operations'
 
 const { FF_WORKSPACES_NEW_PLANS_ENABLED } = getFeatureFlags()
 
@@ -482,6 +495,60 @@ const onWorkspaceCreatedFactory =
     await assignWorkspaceSeat({ userId: createdByUserId, workspaceId: workspace.id })
   }
 
+const blockInvalidWorkspaceProjectRoleUpdatesFactory =
+  (deps: {
+    getStream: GetStream
+    getWorkspaceRoleForUser: GetWorkspaceRoleForUser
+    getWorkspaceUserSeat: GetWorkspaceUserSeat
+    getWorkspacePlan: GetWorkspacePlan
+  }) =>
+  async ({ payload }: EventPayload<typeof ProjectEvents.PermissionsBeingAdded>) => {
+    const project = await deps.getStream({ streamId: payload.projectId })
+    if (!project?.workspaceId) return // No extra validation necessary
+
+    const roleSeatParams = {
+      workspaceId: project.workspaceId,
+      userId: payload.targetUserId
+    }
+    const [currentWorkspaceRole, seat, plan] = await Promise.all([
+      deps.getWorkspaceRoleForUser(roleSeatParams),
+      deps.getWorkspaceUserSeat(roleSeatParams),
+      deps.getWorkspacePlan({ workspaceId: project.workspaceId })
+    ])
+
+    // Workspace role checks
+    if (currentWorkspaceRole?.role === Roles.Workspace.Admin) {
+      // User is workspace admin and cannot have their project roles changed
+      throw new WorkspaceAdminError()
+    }
+
+    if (
+      currentWorkspaceRole?.role === Roles.Workspace.Guest &&
+      payload.role === Roles.Stream.Owner
+    ) {
+      // Workspace guests cannot be project owners
+      throw new WorkspaceInvalidRoleError('Workspace guests cannot be project owners.')
+    }
+
+    // Workspace seat checks
+    if (
+      !plan ||
+      !seat ||
+      !(Object.values(PaidWorkspacePlansNew) as string[]).includes(plan.name)
+    ) {
+      return // Doesn't apply
+    }
+
+    if (
+      seat.type === WorkspaceSeatType.Viewer &&
+      payload.role !== Roles.Stream.Reviewer
+    ) {
+      throw new WorkspaceInvalidRoleError(
+        'Workspace viewers can only be project reviewers.'
+      )
+    }
+  }
+
 export const initializeEventListenersFactory =
   ({ db }: { db: Knex }) =>
   () => {
@@ -491,6 +558,17 @@ export const initializeEventListenersFactory =
     const emitWorkspaceGraphqlSubscriptions = emitWorkspaceGraphqlSubscriptionsFactory({
       getWorkspace
     })
+    const getStream = getStreamFactory({ db })
+    const getWorkspaceRoleForUser = getWorkspaceRoleForUserFactory({ db })
+    const getWorkspaceUserSeat = getWorkspaceUserSeatFactory({ db })
+    const getWorkspacePlan = getWorkspacePlanFactory({ db })
+    const blockInvalidWorkspaceProjectRoleUpdates =
+      blockInvalidWorkspaceProjectRoleUpdatesFactory({
+        getStream,
+        getWorkspaceRoleForUser,
+        getWorkspaceUserSeat,
+        getWorkspacePlan
+      })
 
     const quitCbs = [
       eventBus.listen(ProjectEvents.Created, async ({ payload }) => {
@@ -524,7 +602,7 @@ export const initializeEventListenersFactory =
           getDefaultRegion: getDefaultRegionFactory({ db }),
           getUserEmails: findEmailsByUserIdFactory({ db }),
           getWorkspace: getWorkspaceFactory({ db }),
-          getWorkspacePlan: getWorkspacePlanFactory({ db }),
+          getWorkspacePlan,
           getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db })
         })(payload)
       }),
@@ -534,7 +612,7 @@ export const initializeEventListenersFactory =
           getDefaultRegion: getDefaultRegionFactory({ db }),
           getUserEmails: findEmailsByUserIdFactory({ db }),
           getWorkspace: getWorkspaceFactory({ db }),
-          getWorkspacePlan: getWorkspacePlanFactory({ db }),
+          getWorkspacePlan,
           getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db })
         })(payload)
       }),
@@ -582,7 +660,11 @@ export const initializeEventListenersFactory =
         })
         await withTransaction(onWorkspaceCreated(payload), trx)
       }),
-      eventBus.listen('**', emitWorkspaceGraphqlSubscriptions)
+      eventBus.listen('**', emitWorkspaceGraphqlSubscriptions),
+      eventBus.listen(
+        ProjectEvents.PermissionsBeingAdded,
+        blockInvalidWorkspaceProjectRoleUpdates
+      )
     ]
 
     return () => quitCbs.forEach((quit) => quit())
diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts
index 5bd2750ecc8..62b8941c52e 100644
--- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts
+++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts
@@ -100,8 +100,7 @@ import {
   getWorkspaceProjectsFactory,
   getWorkspaceRoleToDefaultProjectRoleMappingFactory,
   moveProjectToWorkspaceFactory,
-  queryAllWorkspaceProjectsFactory,
-  updateWorkspaceProjectRoleFactory
+  queryAllWorkspaceProjectsFactory
 } from '@/modules/workspaces/services/projects'
 import {
   getDiscoverableWorkspacesForUserFactory,
@@ -945,19 +944,17 @@ export = FF_WORKSPACES_MODULE_ENABLED
           return project
         },
         updateRole: async (_parent, args, context) => {
-          const updateWorkspaceProjectRole = updateWorkspaceProjectRoleFactory({
-            getStream,
-            updateStreamRoleAndNotify,
-            getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db })
-          })
-
-          return await updateWorkspaceProjectRole({
-            role: args.input,
-            updater: {
-              userId: context.userId!,
-              resourceAccessRules: context.resourceAccessRules
-            }
-          })
+          await authorizeResolver(
+            context.userId,
+            args.input.projectId,
+            Roles.Stream.Owner,
+            context.resourceAccessRules
+          )
+          return await updateStreamRoleAndNotify(
+            args.input,
+            context.userId!,
+            context.resourceAccessRules
+          )
         },
         moveToWorkspace: async (_parent, args, context) => {
           const { projectId, workspaceId } = args
diff --git a/packages/server/modules/workspaces/services/projects.ts b/packages/server/modules/workspaces/services/projects.ts
index a3d2199ef6c..957bfee9126 100644
--- a/packages/server/modules/workspaces/services/projects.ts
+++ b/packages/server/modules/workspaces/services/projects.ts
@@ -2,17 +2,13 @@ import { StreamRecord } from '@/modules/core/helpers/types'
 import {
   GetDefaultRegion,
   GetWorkspace,
-  GetWorkspaceRoleForUser,
   GetWorkspaceRoles,
   GetWorkspaceRoleToDefaultProjectRoleMapping,
   QueryAllWorkspaceProjects,
-  UpdateWorkspaceProjectRole,
   UpdateWorkspaceRole
 } from '@/modules/workspaces/domain/operations'
 import {
-  WorkspaceAdminError,
   WorkspaceInvalidProjectError,
-  WorkspaceInvalidRoleError,
   WorkspaceNotFoundError,
   WorkspaceQueryError
 } from '@/modules/workspaces/errors/workspace'
@@ -27,10 +23,8 @@ import { Roles, StreamRoles } from '@speckle/shared'
 import { orderByWeight } from '@/modules/shared/domain/rolesAndScopes/logic'
 import coreUserRoles from '@/modules/core/roles'
 import {
-  GetStream,
   GetUserStreamsPage,
-  LegacyGetStreams,
-  UpdateStreamRole
+  LegacyGetStreams
 } from '@/modules/core/domain/streams/operations'
 import { ProjectNotFoundError } from '@/modules/core/errors/projects'
 import { WorkspaceProjectCreateInput } from '@/test/graphql/generated/graphql'
@@ -226,45 +220,6 @@ export const getWorkspaceRoleToDefaultProjectRoleMappingFactory =
     }
   }
 
-export const updateWorkspaceProjectRoleFactory =
-  ({
-    getStream,
-    getWorkspaceRoleForUser,
-    updateStreamRoleAndNotify
-  }: {
-    getStream: GetStream
-    getWorkspaceRoleForUser: GetWorkspaceRoleForUser
-    updateStreamRoleAndNotify: UpdateStreamRole
-  }): UpdateWorkspaceProjectRole =>
-  async ({ role, updater }) => {
-    const { workspaceId } = (await getStream({ streamId: role.projectId })) ?? {}
-    if (!workspaceId) throw new WorkspaceInvalidProjectError()
-
-    const currentWorkspaceRole = await getWorkspaceRoleForUser({
-      workspaceId,
-      userId: role.userId
-    })
-
-    if (currentWorkspaceRole?.role === Roles.Workspace.Admin) {
-      // User is workspace admin and cannot have their project roles changed
-      throw new WorkspaceAdminError()
-    }
-
-    if (
-      currentWorkspaceRole?.role === Roles.Workspace.Guest &&
-      role.role === Roles.Stream.Owner
-    ) {
-      // Workspace guests cannot be project owners
-      throw new WorkspaceInvalidRoleError('Workspace guests cannot be project owners.')
-    }
-
-    return await updateStreamRoleAndNotify(
-      role,
-      updater.userId!,
-      updater.resourceAccessRules
-    )
-  }
-
 export const createWorkspaceProjectFactory =
   (deps: { getDefaultRegion: GetDefaultRegion }) =>
   async (params: { input: WorkspaceProjectCreateInput; ownerId: string }) => {
diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts
index d3fc30f29aa..1d6a523f3c7 100644
--- a/packages/server/modules/workspaces/tests/helpers/creation.ts
+++ b/packages/server/modules/workspaces/tests/helpers/creation.ts
@@ -19,7 +19,8 @@ import {
   getWorkspaceWithDomainsFactory,
   getWorkspaceDomainsFactory,
   storeWorkspaceDomainFactory,
-  getWorkspaceBySlugFactory
+  getWorkspaceBySlugFactory,
+  getWorkspaceRoleForUserFactory
 } from '@/modules/workspaces/repositories/workspaces'
 import {
   buildWorkspaceInviteEmailContentsFactory,
@@ -75,6 +76,9 @@ import {
 } from '@/modules/workspaces/repositories/regions'
 import { getDb } from '@/modules/multiregion/utils/dbSelector'
 import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
+import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
+import { assignWorkspaceSeatFactory } from '@/modules/workspaces/services/workspaceSeat'
+import { createWorkspaceSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
 
 const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
 
@@ -244,7 +248,8 @@ export const createTestWorkspace = async (
 export const assignToWorkspace = async (
   workspace: BasicTestWorkspace,
   user: BasicTestUser,
-  role?: WorkspaceRoles
+  role?: WorkspaceRoles,
+  seatType?: WorkspaceSeatType
 ) => {
   const updateWorkspaceRole = updateWorkspaceRoleFactory({
     getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }),
@@ -253,12 +258,26 @@ export const assignToWorkspace = async (
     upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
     emitWorkspaceEvent: (...args) => getEventBus().emit(...args)
   })
+  const assignWorkspaceSeat = assignWorkspaceSeatFactory({
+    createWorkspaceSeat: createWorkspaceSeatFactory({ db }),
+    getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db })
+  })
+
+  role = role || Roles.Workspace.Member
 
   await updateWorkspaceRole({
     userId: user.id,
     workspaceId: workspace.id,
-    role: role || Roles.Workspace.Member
+    role
   })
+
+  if (seatType) {
+    await assignWorkspaceSeat({
+      userId: user.id,
+      workspaceId: workspace.id,
+      type: seatType
+    })
+  }
 }
 
 export const unassignFromWorkspace = async (
diff --git a/packages/server/modules/workspaces/tests/helpers/graphql.ts b/packages/server/modules/workspaces/tests/helpers/graphql.ts
index 175ce9b6005..b22806f808b 100644
--- a/packages/server/modules/workspaces/tests/helpers/graphql.ts
+++ b/packages/server/modules/workspaces/tests/helpers/graphql.ts
@@ -1,3 +1,4 @@
+import { basicProjectFieldsFragment } from '@/test/graphql/projects'
 import { gql } from 'graphql-tag'
 
 export const basicWorkspaceFragment = gql`
@@ -361,3 +362,17 @@ export const getWorkspaceWithJoinRequestsQuery = gql`
   }
   ${basicWorkspaceFragment}
 `
+
+export const updateWorkspaceProjectRoleMutation = gql`
+  mutation UpdateWorkspaceProjectRole($input: ProjectUpdateRoleInput!) {
+    workspaceMutations {
+      projects {
+        updateRole(input: $input) {
+          ...BasicProjectFields
+        }
+      }
+    }
+  }
+
+  ${basicProjectFieldsFragment}
+`
diff --git a/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts
index 4bc01591135..b238b9fc039 100644
--- a/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts
+++ b/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts
@@ -4,15 +4,19 @@ import { CommentRecord } from '@/modules/comments/helpers/types'
 import { AllScopes } from '@/modules/core/helpers/mainConstants'
 import { createRandomEmail } from '@/modules/core/helpers/testHelpers'
 import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams'
+import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
+import { getWorkspaceUserSeatsFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
 import { getDb } from '@/modules/multiregion/utils/dbSelector'
 import {
   createWebhookConfigFactory,
   createWebhookEventFactory
 } from '@/modules/webhooks/repositories/webhooks'
 import {
+  assignToWorkspace,
   BasicTestWorkspace,
   createTestWorkspace
 } from '@/modules/workspaces/tests/helpers/creation'
+import { describeEach } from '@/test/assertionHelper'
 import {
   BasicTestUser,
   createAuthTokenForUser,
@@ -33,7 +37,10 @@ import {
   GetWorkspaceProjectsDocument,
   GetWorkspaceTeamDocument,
   MoveProjectToWorkspaceDocument,
-  UpdateProjectRegionDocument
+  ProjectUpdateRoleInput,
+  UpdateProjectRegionDocument,
+  UpdateProjectRoleDocument,
+  UpdateWorkspaceProjectRoleDocument
 } from '@/test/graphql/generated/graphql'
 import {
   createTestContext,
@@ -54,10 +61,17 @@ import {
   createTestObject
 } from '@/test/speckle-helpers/commitHelper'
 import {
+  getMainTestRegionKey,
   isMultiRegionTestMode,
-  waitForRegionUser
+  waitForRegionUser,
+  waitForRegionUsers
 } from '@/test/speckle-helpers/regions'
-import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
+import {
+  addToStream,
+  BasicTestStream,
+  createTestStream,
+  getUserStreamRole
+} from '@/test/speckle-helpers/streamHelper'
 import { Roles } from '@speckle/shared'
 import { expect } from 'chai'
 import cryptoRandomString from 'crypto-random-string'
@@ -119,6 +133,175 @@ describe('Workspace project GQL CRUD', () => {
     )
   })
 
+  describe('when changing workspace project roles', () => {
+    const workspaceGuest: BasicTestUser = {
+      id: '',
+      name: 'John Guest 2',
+      email: 'johnguest2@bababooey.com'
+    }
+
+    const workspaceEditor: BasicTestUser = {
+      id: '',
+      name: 'John Editor 2',
+      email: 'johneditor2@bababooey.com'
+    }
+
+    const workspaceMemberViewer: BasicTestUser = {
+      id: '',
+      name: 'John Member Viewer',
+      email: 'johnmemberviewer@bababooey.com'
+    }
+
+    before(async () => {
+      await Promise.all([
+        createTestUser(workspaceGuest),
+        createTestUser(workspaceEditor),
+        createTestUser(workspaceMemberViewer)
+      ])
+      await waitForRegionUsers([
+        serverAdminUser,
+        workspaceGuest,
+        workspaceEditor,
+        workspaceMemberViewer
+      ])
+    })
+
+    describeEach(
+      [{ oldPlan: true }, { oldPlan: false }],
+      ({ oldPlan }) => `with ${oldPlan ? 'old (business)' : 'new (pro)'} plan`,
+      ({ oldPlan }) => {
+        const roleProject: BasicTestStream = {
+          name: 'Role Project',
+          isPublic: false,
+          id: '',
+          ownerId: ''
+        }
+
+        const roleWorkspace: BasicTestWorkspace = {
+          id: '',
+          ownerId: '',
+          slug: cryptoRandomString({ length: 10 }),
+          name: 'Role Workspace'
+        }
+
+        before(async () => {
+          // TODO: Multiregion
+          await createTestWorkspace(roleWorkspace, serverAdminUser, {
+            addPlan: oldPlan
+              ? { name: 'business', status: 'valid' }
+              : { name: 'pro', status: 'valid' },
+            regionKey: isMultiRegionTestMode() ? getMainTestRegionKey() : undefined
+          })
+          roleProject.workspaceId = roleWorkspace.id
+
+          await Promise.all([
+            assignToWorkspace(roleWorkspace, workspaceGuest, Roles.Workspace.Guest),
+            assignToWorkspace(
+              roleWorkspace,
+              workspaceEditor,
+              Roles.Workspace.Member,
+              WorkspaceSeatType.Editor
+            ),
+            assignToWorkspace(
+              roleWorkspace,
+              workspaceMemberViewer,
+              Roles.Workspace.Member,
+              WorkspaceSeatType.Viewer
+            )
+          ])
+          await createTestStream(roleProject, serverAdminUser)
+
+          await Promise.all([
+            addToStream(roleProject, workspaceGuest, Roles.Stream.Reviewer),
+            addToStream(roleProject, workspaceEditor, Roles.Stream.Contributor),
+            addToStream(roleProject, workspaceMemberViewer, Roles.Stream.Reviewer)
+          ])
+
+          // assert seat types
+          const seats = await getWorkspaceUserSeatsFactory({ db })({
+            workspaceId: roleWorkspace.id,
+            userIds: [workspaceGuest.id, workspaceEditor.id, workspaceMemberViewer.id]
+          })
+          expect(seats[workspaceGuest.id].type).to.equal(WorkspaceSeatType.Viewer)
+          expect(seats[workspaceEditor.id].type).to.equal(WorkspaceSeatType.Editor)
+          expect(seats[workspaceMemberViewer.id].type).to.equal(
+            WorkspaceSeatType.Viewer
+          )
+        })
+
+        describeEach(
+          [{ oldResolver: true }, { oldResolver: false }],
+          ({ oldResolver }) =>
+            `with ${oldResolver ? 'old' : 'new'} updateRole resolver`,
+          ({ oldResolver }) => {
+            const updateRole = async (input: ProjectUpdateRoleInput) => {
+              if (oldResolver) {
+                const res = await apollo.execute(UpdateProjectRoleDocument, {
+                  input
+                })
+                const project = res.data?.projectMutations?.updateRole
+                return { res, project }
+              } else {
+                const res = await apollo.execute(UpdateWorkspaceProjectRoleDocument, {
+                  input
+                })
+                const project = res.data?.workspaceMutations?.projects?.updateRole
+                return { res, project }
+              }
+            }
+
+            it("can't set a workspace guest as a project owner", async () => {
+              const { res } = await updateRole({
+                projectId: roleProject.id,
+                userId: workspaceGuest.id,
+                role: Roles.Stream.Owner
+              })
+              const newRole = await getUserStreamRole(workspaceGuest.id, roleProject.id)
+
+              expect(res).to.haveGraphQLErrors(
+                'Workspace guests cannot be project owners'
+              )
+              expect(newRole).to.eq(Roles.Stream.Reviewer)
+            })
+
+            it(`can${
+              oldPlan ? '' : 'not'
+            } set a workspace viewer as a project contributor or owner`, async () => {
+              const { res: resA } = await updateRole({
+                projectId: roleProject.id,
+                userId: workspaceMemberViewer.id,
+                role: Roles.Stream.Contributor
+              })
+              const { res: resB } = await updateRole({
+                projectId: roleProject.id,
+                userId: workspaceMemberViewer.id,
+                role: Roles.Stream.Owner
+              })
+              const newRole = await getUserStreamRole(
+                workspaceMemberViewer.id,
+                roleProject.id
+              )
+
+              if (oldPlan) {
+                expect(resA).to.not.haveGraphQLErrors()
+                expect(resB).to.not.haveGraphQLErrors()
+                expect(newRole).to.eq(Roles.Stream.Owner)
+              } else {
+                expect(resA).to.haveGraphQLErrors(
+                  'Workspace viewers can only be project reviewers.'
+                )
+                expect(resB).to.haveGraphQLErrors(
+                  'Workspace viewers can only be project reviewers.'
+                )
+                expect(newRole).to.eq(Roles.Stream.Reviewer)
+              }
+            })
+          }
+        )
+      }
+    )
+  })
+
   describe('when specifying a workspace id during project creation', () => {
     it('should create the project in that workspace', async () => {
       const projectName = cryptoRandomString({ length: 6 })
@@ -209,8 +392,7 @@ describe('Workspace project GQL CRUD', () => {
 
       expect(res).to.not.haveGraphQLErrors()
       expect(projects).to.exist
-      expect(projects?.every((project) => project?.workspace?.id === workspace.id)).to
-        .be.true
+      expect(projects?.every((project) => !!project?.workspace?.id)).to.be.ok
     })
   })
 
diff --git a/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts b/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts
index 8dc02d1602c..48fbc942b72 100644
--- a/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts
+++ b/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts
@@ -1,17 +1,15 @@
 import { ProjectTeamMember } from '@/modules/core/domain/projects/types'
-import { Stream } from '@/modules/core/domain/streams/types'
 import { ProjectNotFoundError } from '@/modules/core/errors/projects'
 import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
 import { WorkspaceInvalidProjectError } from '@/modules/workspaces/errors/workspace'
 import {
   moveProjectToWorkspaceFactory,
-  queryAllWorkspaceProjectsFactory,
-  updateWorkspaceProjectRoleFactory
+  queryAllWorkspaceProjectsFactory
 } from '@/modules/workspaces/services/projects'
 import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
 import { expectToThrow } from '@/test/assertionHelper'
 import { Roles } from '@speckle/shared'
-import { assert, expect } from 'chai'
+import { expect } from 'chai'
 import cryptoRandomString from 'crypto-random-string'
 
 const getWorkspaceRoleToDefaultProjectRoleMapping = async () => ({
@@ -429,33 +427,4 @@ describe('Project management services', () => {
       expect(updatedRoles[0].role).to.equal(Roles.Stream.Owner)
     })
   })
-  describe('updateWorkspaceProjectRoleFactory returns a function, that', () => {
-    it('should throw when attempting to promote a workspace guest to project owner', async () => {
-      const workspaceId = cryptoRandomString({ length: 9 })
-      const projectId = cryptoRandomString({ length: 9 })
-      const userId = cryptoRandomString({ length: 9 })
-
-      const updateWorkspaceProjectRole = updateWorkspaceProjectRoleFactory({
-        getStream: async () => {
-          return { workspaceId } as Stream
-        },
-        getWorkspaceRoleForUser: async () => ({
-          workspaceId,
-          userId,
-          role: Roles.Workspace.Guest,
-          createdAt: new Date()
-        }),
-        updateStreamRoleAndNotify: async () => {
-          assert.fail()
-        }
-      })
-
-      await expectToThrow(() =>
-        updateWorkspaceProjectRole({
-          role: { userId, projectId, role: Roles.Stream.Owner },
-          updater: { userId: '', resourceAccessRules: [] }
-        })
-      )
-    })
-  })
 })
diff --git a/packages/server/package.json b/packages/server/package.json
index 414cb31a14b..656a20a683e 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -39,7 +39,8 @@
     "migrate": "yarn cli db migrate",
     "migrate:test": "cross-env NODE_ENV=test ts-node ./modules/cli/index.js db migrate",
     "gqlgen": "graphql-codegen --config codegen.yml",
-    "gqlgen:watch": "graphql-codegen --config codegen.yml --watch \"assets/**/*.graphql\""
+    "gqlgen:watch": "graphql-codegen --config codegen.yml --watch \"assets/**/*.graphql\"",
+    "stripe:listen": "stripe listen --forward-to 127.0.0.1:3000/api/v1/billing/webhooks"
   },
   "dependencies": {
     "@apollo/client": "^3.7.0",
diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts
index 840f2fe39ce..4ad9f991367 100644
--- a/packages/server/test/graphql/generated/graphql.ts
+++ b/packages/server/test/graphql/generated/graphql.ts
@@ -5143,6 +5143,13 @@ export type GetWorkspaceWithJoinRequestsQueryVariables = Exact<{
 
 export type GetWorkspaceWithJoinRequestsQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, name: string, slug: string, updatedAt: string, createdAt: string, role?: string | null, readOnly: boolean, adminWorkspacesJoinRequests?: { __typename?: 'WorkspaceJoinRequestCollection', cursor?: string | null, totalCount: number, items: Array<{ __typename?: 'WorkspaceJoinRequest', status: WorkspaceJoinRequestStatus, createdAt: string, user: { __typename?: 'LimitedUser', id: string, name: string }, workspace: { __typename?: 'Workspace', id: string, name: string } }> } | null } };
 
+export type UpdateWorkspaceProjectRoleMutationVariables = Exact<{
+  input: ProjectUpdateRoleInput;
+}>;
+
+
+export type UpdateWorkspaceProjectRoleMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', projects: { __typename?: 'WorkspaceProjectMutations', updateRole: { __typename?: 'Project', id: string, name: string, description?: string | null, visibility: ProjectVisibility, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string } } } };
+
 export type BasicStreamAccessRequestFieldsFragment = { __typename?: 'StreamAccessRequest', id: string, requesterId: string, streamId: string, createdAt: string, requester: { __typename?: 'LimitedUser', id: string, name: string } };
 
 export type CreateStreamAccessRequestMutationVariables = Exact<{
@@ -5526,6 +5533,13 @@ export type BatchDeleteProjectsMutationVariables = Exact<{
 
 export type BatchDeleteProjectsMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', batchDelete: boolean } };
 
+export type UpdateProjectRoleMutationVariables = Exact<{
+  input: ProjectUpdateRoleInput;
+}>;
+
+
+export type UpdateProjectRoleMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', updateRole: { __typename?: 'Project', id: string, name: string, description?: string | null, visibility: ProjectVisibility, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string } } };
+
 export type CreateServerInviteMutationVariables = Exact<{
   input: ServerInviteCreateInput;
 }>;
@@ -5955,6 +5969,7 @@ export const OnWorkspaceUpdatedDocument = {"kind":"Document","definitions":[{"ki
 export const DismissWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"dismissWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDismissInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dismiss"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode<DismissWorkspaceMutation, DismissWorkspaceMutationVariables>;
 export const RequestToJoinWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"requestToJoinWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceRequestToJoinInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requestToJoin"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode<RequestToJoinWorkspaceMutation, RequestToJoinWorkspaceMutationVariables>;
 export const GetWorkspaceWithJoinRequestsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceWithJoinRequests"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"AdminWorkspaceJoinRequestFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"adminWorkspacesJoinRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}}]} as unknown as DocumentNode<GetWorkspaceWithJoinRequestsQuery, GetWorkspaceWithJoinRequestsQueryVariables>;
+export const UpdateWorkspaceProjectRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceProjectRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectUpdateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projects"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<UpdateWorkspaceProjectRoleMutation, UpdateWorkspaceProjectRoleMutationVariables>;
 export const CreateStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequestCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode<CreateStreamAccessRequestMutation, CreateStreamAccessRequestMutationVariables>;
 export const GetStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode<GetStreamAccessRequestQuery, GetStreamAccessRequestQueryVariables>;
 export const GetFullStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetFullStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}},{"kind":"Field","name":{"kind":"Name","value":"stream"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode<GetFullStreamAccessRequestQuery, GetFullStreamAccessRequestQueryVariables>;
@@ -6004,6 +6019,7 @@ export const GetProjectObjectDocument = {"kind":"Document","definitions":[{"kind
 export const GetProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}}]}}]}}]} as unknown as DocumentNode<GetProjectQuery, GetProjectQueryVariables>;
 export const CreateProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<CreateProjectMutation, CreateProjectMutationVariables>;
 export const BatchDeleteProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BatchDeleteProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"batchDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}]}]}}]}}]} as unknown as DocumentNode<BatchDeleteProjectsMutation, BatchDeleteProjectsMutationVariables>;
+export const UpdateProjectRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateProjectRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectUpdateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<UpdateProjectRoleMutation, UpdateProjectRoleMutationVariables>;
 export const CreateServerInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServerInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInviteCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<CreateServerInviteMutation, CreateServerInviteMutationVariables>;
 export const CreateStreamInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStreamInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"StreamInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamInviteCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<CreateStreamInviteMutation, CreateStreamInviteMutationVariables>;
 export const ResendInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResendInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteResend"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}]}]}}]} as unknown as DocumentNode<ResendInviteMutation, ResendInviteMutationVariables>;
diff --git a/packages/server/test/graphql/projects.ts b/packages/server/test/graphql/projects.ts
index 08c7a354584..5ddd0321ece 100644
--- a/packages/server/test/graphql/projects.ts
+++ b/packages/server/test/graphql/projects.ts
@@ -81,3 +81,15 @@ export const batchDeleteProjectsMutation = gql`
     }
   }
 `
+
+export const updateProjectRoleMutation = gql`
+  mutation UpdateProjectRole($input: ProjectUpdateRoleInput!) {
+    projectMutations {
+      updateRole(input: $input) {
+        ...BasicProjectFields
+      }
+    }
+  }
+
+  ${basicProjectFieldsFragment}
+`


Reviews:
user: login: fabis94
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T15:12:23Z
user: login: fabis94
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T15:12:42Z
user: login: fabis94
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T15:13:11Z
user: login: fabis94
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T15:13:19Z
user: login: fabis94
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T15:13:46Z
user: login: alemagio
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T10:12:30Z
user: login: fabis94
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T10:14:33Z
user: login: fabis94
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T10:20:39Z

Review comments:
diff_hunk: @@ -177,6 +177,17 @@ export const addOrUpdateStreamCollaboratorFactory =
         throw new BadRequestError('Server guests cannot own streams')
     }
 
+    // Allows for dynamic extra validation
+    await deps.emitEvent({
+      eventName: ProjectEvents.PermissionsBeingAdded,
path: packages/server/modules/core/services/streams/access.ts
user: login: fabis94
body: Even without hooks we can use the existing eventBus system to allow dynamic validation (based on whether workspaces are on or not), which allowed me to unify the normal project role update and workspace project role update resolvers and fix the bug where the normal one allowed workspace guests to become project owners

cc @cdriesler 
created_at: 2025-03-05T15:12:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,13 +62,15 @@ export const createNewProjectFactory =
             const replicatedProject = await getProject({ projectId })
             if (!replicatedProject) throw new StreamNotFoundError()
           },
-          { maxAttempts: 10 }
+          { maxAttempts: 10, delay: isTestEnv() ? 1000 : undefined }
path: packages/server/modules/core/services/projects.ts
user: login: fabis94
body: default settings were not enough locally, I was getting random failures here
created_at: 2025-03-05T15:12:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -482,6 +495,60 @@ const onWorkspaceCreatedFactory =
     await assignWorkspaceSeat({ userId: createdByUserId, workspaceId: workspace.id })
   }
 
+const blockInvalidWorkspaceProjectRoleUpdatesFactory =
+  (deps: {
+    getStream: GetStream
+    getWorkspaceRoleForUser: GetWorkspaceRoleForUser
+    getWorkspaceUserSeat: GetWorkspaceUserSeat
+    getWorkspacePlan: GetWorkspacePlan
+  }) =>
+  async ({ payload }: EventPayload<typeof ProjectEvents.PermissionsBeingAdded>) => {
+    const project = await deps.getStream({ streamId: payload.projectId })
path: packages/server/modules/workspaces/events/eventListener.ts
user: login: fabis94
body: moved chuck's project role protection logic here
created_at: 2025-03-05T15:13:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -482,6 +495,60 @@ const onWorkspaceCreatedFactory =
     await assignWorkspaceSeat({ userId: createdByUserId, workspaceId: workspace.id })
   }
 
+const blockInvalidWorkspaceProjectRoleUpdatesFactory =
+  (deps: {
+    getStream: GetStream
+    getWorkspaceRoleForUser: GetWorkspaceRoleForUser
+    getWorkspaceUserSeat: GetWorkspaceUserSeat
+    getWorkspacePlan: GetWorkspacePlan
+  }) =>
+  async ({ payload }: EventPayload<typeof ProjectEvents.PermissionsBeingAdded>) => {
+    const project = await deps.getStream({ streamId: payload.projectId })
+    if (!project?.workspaceId) return // No extra validation necessary
+
+    const roleSeatParams = {
+      workspaceId: project.workspaceId,
+      userId: payload.targetUserId
+    }
+    const [currentWorkspaceRole, seat, plan] = await Promise.all([
+      deps.getWorkspaceRoleForUser(roleSeatParams),
+      deps.getWorkspaceUserSeat(roleSeatParams),
+      deps.getWorkspacePlan({ workspaceId: project.workspaceId })
+    ])
+
+    // Workspace role checks
+    if (currentWorkspaceRole?.role === Roles.Workspace.Admin) {
+      // User is workspace admin and cannot have their project roles changed
+      throw new WorkspaceAdminError()
+    }
+
+    if (
+      currentWorkspaceRole?.role === Roles.Workspace.Guest &&
+      payload.role === Roles.Stream.Owner
+    ) {
+      // Workspace guests cannot be project owners
+      throw new WorkspaceInvalidRoleError('Workspace guests cannot be project owners.')
+    }
+
+    // Workspace seat checks
path: packages/server/modules/workspaces/events/eventListener.ts
user: login: fabis94
body: + Added the new seat checks below
created_at: 2025-03-05T15:13:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -119,6 +133,175 @@ describe('Workspace project GQL CRUD', () => {
     )
   })
 
+  describe('when changing workspace project roles', () => {
path: packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts
user: login: fabis94
body: tests validate logic w/ old & new resolver and w/ old and new plans
created_at: 2025-03-05T15:13:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -173,7 +173,12 @@ export type ReconcileSubscriptionData = (args: {
   applyProrotation: boolean
 }) => Promise<void>
 
-export type WorkspaceSeatType = 'viewer' | 'editor'
+export const WorkspaceSeatType = <const>{
+  Viewer: 'viewer',
+  Editor: 'editor'
+}
+export type WorkspaceSeatType =
+  (typeof WorkspaceSeatType)[keyof typeof WorkspaceSeatType]
path: packages/server/modules/gatekeeper/domain/billing.ts
user: login: alemagio
body: I would like to avoid these kind of changes.
I thought we agreed that union string types are fine

cc
@gjedlicska 
created_at: 2025-03-06T10:07:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -173,7 +173,12 @@ export type ReconcileSubscriptionData = (args: {
   applyProrotation: boolean
 }) => Promise<void>
 
-export type WorkspaceSeatType = 'viewer' | 'editor'
+export const WorkspaceSeatType = <const>{
+  Viewer: 'viewer',
+  Editor: 'editor'
+}
+export type WorkspaceSeatType =
+  (typeof WorkspaceSeatType)[keyof typeof WorkspaceSeatType]
path: packages/server/modules/gatekeeper/domain/billing.ts
user: login: fabis94
body: it's the same type it was before, except now you can also use it as a runtime enum. I don't see what the problem is with that
created_at: 2025-03-06T10:14:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -173,7 +173,12 @@ export type ReconcileSubscriptionData = (args: {
   applyProrotation: boolean
 }) => Promise<void>
 
-export type WorkspaceSeatType = 'viewer' | 'editor'
+export const WorkspaceSeatType = <const>{
+  Viewer: 'viewer',
+  Editor: 'editor'
+}
+export type WorkspaceSeatType =
+  (typeof WorkspaceSeatType)[keyof typeof WorkspaceSeatType]
path: packages/server/modules/gatekeeper/domain/billing.ts
user: login: fabis94
body: i think magic string literals are bad practice - 1) the autocomplete only works when you're putting them into a constant/param that is already typed as the expected type. if you're just doing `const a = ...` and start typing the string, its not gonna know `a`'s expected type and you won't get autocomplete. don't have that issue with enums 
2) duplicating strings over and over again makes it more difficult to change the value in the future

that said, i'm ok with someone here disagreeing - that's why there's both options available. you can still do the magic strings if you want, but at least there's the option to use the enum if you prefer that
created_at: 2025-03-06T10:20:39Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-03-05T14:05:07Z
body: <p><a href="https://linear.app/speckle/issue/WEB-2736/viewer-seat-stream-role-control">WEB-2736 Viewer seat stream role control</a></p>
author_association: NONE
user: login: codecov[bot]
created_at: 2025-03-05T14:17:01Z
body: ## [Codecov](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4117?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems) Report
Attention: Patch coverage is `94.33962%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 71.95%. Comparing base [(`ff9d9b6`)](https://app.codecov.io/gh/specklesystems/speckle-server/commit/ff9d9b64fdeaf74b7f2957d2fe33f9e174386db5?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems) to head [(`674c55a`)](https://app.codecov.io/gh/specklesystems/speckle-server/commit/674c55aab6ad79084c16cc7dc0893e1aa160cede?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems).
> Report is 1755 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4117?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems) | Patch % | Lines |
|---|---|---|
| [.../server/modules/workspaces/events/eventListener.ts](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4117?src=pr&el=tree&filepath=packages%2Fserver%2Fmodules%2Fworkspaces%2Fevents%2FeventListener.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems#diff-cGFja2FnZXMvc2VydmVyL21vZHVsZXMvd29ya3NwYWNlcy9ldmVudHMvZXZlbnRMaXN0ZW5lci50cw==) | 92.00% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4117?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems) |
| [packages/server/modules/core/services/projects.ts](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4117?src=pr&el=tree&filepath=packages%2Fserver%2Fmodules%2Fcore%2Fservices%2Fprojects.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems#diff-cGFja2FnZXMvc2VydmVyL21vZHVsZXMvY29yZS9zZXJ2aWNlcy9wcm9qZWN0cy50cw==) | 66.66% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4117?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #4117      +/-   ##
==========================================
+ Coverage   71.83%   71.95%   +0.12%     
==========================================
  Files         410      410              
  Lines       18472    18505      +33     
  Branches     2997     3002       +5     
==========================================
+ Hits        13270    13316      +46     
+ Misses       4187     4181       -6     
+ Partials     1015     1008       -7     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4117?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE