toeverything/AFFiNE · error · ActionForbiddenOnNonTeamWorkspace

action_forbidden_on_non_team_workspace

action_forbidden_on_non_team_workspace

Error message

A Team workspace is required to perform this action.

What it means

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.

Solutions

  1. Gate the role-management UI on the workspace plan: only show per-member roles when the workspace is a Team workspace.
  2. If granular roles are required, upgrade the workspace to Team (or apply a Team license on self-hosted).
  3. Use setOwner / grantMember(..., Owner) when the intent is ownership transfer, which works on non-team workspaces.

Example fix

// before
await grantMember(ws.id, userId, WorkspaceRole.Admin); // free workspace -> 403

// after
if (!(await isTeamWorkspace(ws.id))) {
  showNotice('Per-member roles require a Team workspace');
  return;
}
await grantMember(ws.id, userId, WorkspaceRole.Admin);
Defensive patterns

Strategy: validation

Validate before calling

// only expose per-member roles on Team workspaces
const isTeam = await isTeamWorkspace(workspaceId);
if (!isTeam && newRole !== WorkspaceRole.Owner) {
  showNotice('Per-member roles require a Team workspace');
  return;
}

Type guard

function isNonTeamWorkspaceForbidden(e: unknown): boolean {
  const ext = (e as { extensions?: Record<string, unknown> })?.extensions;
  return String(ext?.name ?? '').toLowerCase() === 'action_forbidden_on_non_team_workspace';
}

Try / catch

try {
  await grantMember(workspaceId, userId, newRole);
} catch (e) {
  if (isNonTeamWorkspaceForbidden(e)) {
    showUpgradeToTeamPrompt(); // structural limit — do not retry
  } else throw e;
}

Prevention

When it happens

Trigger: 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).

Common situations: 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.

Related errors


AI-assisted analysis of toeverything/AFFiNE@4953682779 (2026-08-18). Data as JSON: /api/errors/1003b65c9fcd1fc1. Report an issue: GitHub.

Appendix: source

Thrown at packages/backend/server/src/core/workspaces/resolvers/member.ts:544

      .assert(
        newRole === WorkspaceRole.Owner
          ? 'Workspace.TransferOwner'
          : 'Workspace.Users.Manage'
      );

    const role = await this.models.workspaceUser.get(workspaceId, userId);

    if (!role) {
      throw new MemberNotFoundInSpace({ spaceId: workspaceId });
    }

    if (newRole === WorkspaceRole.Owner) {
      await this.models.workspaceUser.setOwner(workspaceId, userId);
    } else {
      // non-team workspace can only transfer ownership, but no detailed permission control
      const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
      if (!isTeam) {
        throw new ActionForbiddenOnNonTeamWorkspace();
      }

      await this.models.workspaceUser.set(workspaceId, userId, newRole);
      if (role.status !== WorkspaceMemberStatus.Accepted) {
        this.event.emit('workspace.members.updated', {
          workspaceId,
        });
      }
    }

    return true;
  }

  @Throttle('strict')
  @Query(() => InvitationType, {
    description: 'get workspace invitation info',
  })
  async getInviteInfo(

View on GitHub (pinned to 4953682779)