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
- 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.
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
- Gate permission-management UI on the workspace plan.
- Ownership transfer is the only role op allowed on non-team workspaces — use it deliberately.
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)