{"record":{"id":"1003b65c9fcd1fc1","repo":"toeverything/AFFiNE","slug":"action-forbidden-on-non-team-workspace","errorCode":"action_forbidden_on_non_team_workspace","errorMessage":"A Team workspace is required to perform this action.","messagePattern":"A Team workspace is required to perform this action\\.","errorType":"exception","errorClass":"ActionForbiddenOnNonTeamWorkspace","httpStatus":403,"severity":"error","filePath":"packages/backend/server/src/core/workspaces/resolvers/member.ts","lineNumber":544,"sourceCode":"      .assert(\n        newRole === WorkspaceRole.Owner\n          ? 'Workspace.TransferOwner'\n          : 'Workspace.Users.Manage'\n      );\n\n    const role = await this.models.workspaceUser.get(workspaceId, userId);\n\n    if (!role) {\n      throw new MemberNotFoundInSpace({ spaceId: workspaceId });\n    }\n\n    if (newRole === WorkspaceRole.Owner) {\n      await this.models.workspaceUser.setOwner(workspaceId, userId);\n    } else {\n      // non-team workspace can only transfer ownership, but no detailed permission control\n      const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);\n      if (!isTeam) {\n        throw new ActionForbiddenOnNonTeamWorkspace();\n      }\n\n      await this.models.workspaceUser.set(workspaceId, userId, newRole);\n      if (role.status !== WorkspaceMemberStatus.Accepted) {\n        this.event.emit('workspace.members.updated', {\n          workspaceId,\n        });\n      }\n    }\n\n    return true;\n  }\n\n  @Throttle('strict')\n  @Query(() => InvitationType, {\n    description: 'get workspace invitation info',\n  })\n  async getInviteInfo(","sourceCodeStart":526,"sourceCodeEnd":562,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/49536827790702ad0ab6d3be3405d891a6b19f83/packages/backend/server/src/core/workspaces/resolvers/member.ts#L526-L562","documentation":"Thrown by grantMember when assigning any role other than Owner on a workspace that is not a Team workspace. Non-team (free/pro personal) workspaces only support ownership transfer — there is no granular per-member permission control — so calls like grantMember(ws, user, Admin/Collaborator) are rejected. Code action_forbidden_on_non_team_workspace, HTTP 403.","triggerScenarios":"Calling grantMember with newRole !== WorkspaceRole.Owner while workspaceService.isTeamWorkspace(workspaceId) is false. The target must already be a member; the team check happens after the member lookup and only on the non-owner path (owner transfer is allowed on any workspace).","commonSituations":"Building one role-management UI for all plans and enabling it on free workspaces; testing admin/permission flows against a personal workspace; users expecting 'Admin' roles on a Pro plan that does not include team features.","solutions":["Gate the role-management UI on the workspace plan: only show per-member roles when the workspace is a Team workspace.","If granular roles are required, upgrade the workspace to Team (or apply a Team license on self-hosted).","Use setOwner / grantMember(..., Owner) when the intent is ownership transfer, which works on non-team workspaces."],"exampleFix":"// before\nawait grantMember(ws.id, userId, WorkspaceRole.Admin); // free workspace -> 403\n\n// after\nif (!(await isTeamWorkspace(ws.id))) {\n  showNotice('Per-member roles require a Team workspace');\n  return;\n}\nawait grantMember(ws.id, userId, WorkspaceRole.Admin);","handlingStrategy":"validation","validationCode":"// only expose per-member roles on Team workspaces\nconst isTeam = await isTeamWorkspace(workspaceId);\nif (!isTeam && newRole !== WorkspaceRole.Owner) {\n  showNotice('Per-member roles require a Team workspace');\n  return;\n}","typeGuard":"function isNonTeamWorkspaceForbidden(e: unknown): boolean {\n  const ext = (e as { extensions?: Record<string, unknown> })?.extensions;\n  return String(ext?.name ?? '').toLowerCase() === 'action_forbidden_on_non_team_workspace';\n}","tryCatchPattern":"try {\n  await grantMember(workspaceId, userId, newRole);\n} catch (e) {\n  if (isNonTeamWorkspaceForbidden(e)) {\n    showUpgradeToTeamPrompt(); // structural limit — do not retry\n  } else throw e;\n}","preventionTips":["Gate permission-management UI on the workspace plan.","Ownership transfer is the only role op allowed on non-team workspaces — use it deliberately."],"tags":["affine","graphql","member-management","plan-limit","forbidden","team-workspace"],"backgroundTag":"feature-requires-team-plan","analyzedSha":"49536827790702ad0ab6d3be3405d891a6b19f83","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}