toeverything/AFFiNE · error · SpaceAccessDenied
space_access_denied
space_access_denied
Error message
You do not have permission to access Space ${spaceId}. What it means
Thrown by QuotaStateRealtimeProvider.assertWorkspace when the realtime handler cannot find an active workspaceUser membership record for the connected user and target workspace. It guards the workspace.quota-state.get live query and the workspace.quota-state.changed topic authorization, so non-members cannot subscribe to another workspace's quota feed.
Source
Thrown at packages/backend/server/src/core/quota/realtime.ts:135
);
}
@OnEvent('workspace.quota_state.changed', { suppressError: true })
async onWorkspaceQuotaStateChanged({
workspaceId,
}: Events['workspace.quota_state.changed']) {
this.publisher?.publish(
'workspace.quota-state.changed',
{ workspaceId },
{ changed: true },
{ room: realtimeWorkspaceQuotaStateRoom(workspaceId) }
);
}
private async assertWorkspace(userId: string, workspaceId: string) {
const role = await this.models.workspaceUser.getActive(workspaceId, userId);
if (!role) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
}
private serializeState<T extends Record<string, unknown>>(state: T) {
return Object.fromEntries(
Object.entries(state).map(([key, value]) => [
key,
typeof value === 'bigint' ? Number(value) : value,
])
);
}
}
View on GitHub (pinned to 26c515e050)
Solutions
- Confirm workspaceUser membership exists (models.workspaceUser.getActive) before subscribing the client.
- Have the client drop the subscription when it receives a workspace membership-revoked event.
- Validate the workspaceId in the envelope belongs to the current user's workspace set on the gateway before forwarding to the provider.
Example fix
// before
await this.assertWorkspace(user.id, payload.workspaceId);
// after
const role = await this.models.workspaceUser.getActive(payload.workspaceId, user.id);
if (!role) throw new SpaceAccessDenied({ spaceId: payload.workspaceId }); Defensive patterns
Strategy: validation
Validate before calling
const role = await models.workspaceUser.getActive(workspaceId, user.id);
if (!role) {
// do not subscribe; tell the client to leave the workspace
socket.emit('workspace.membership.lost', { workspaceId });
return;
} Type guard
// n/a — runtime membership lookup, not a type narrowing
Try / catch
try {
await this.assertWorkspace(user.id, payload.workspaceId);
} catch (e) {
if (e instanceof SpaceAccessDenied) {
// unsubscribe the socket and notify the client
await client.leave(realtimeWorkspaceQuotaStateRoom(payload.workspaceId));
return;
}
throw e;
} Prevention
- Subscribe the client only to workspaces returned by the user's workspace list endpoint.
- On 'workspaceUser.revoked' events, proactively drop the realtime subscription.
- Validate workspaceId format and membership in the gateway before forwarding to the provider.
When it happens
Trigger: Realtime socket emits workspace.quota-state.get or subscribes to workspace.quota-state.changed with a workspaceId the authenticated user is not a member of; user was removed from the workspace while the socket was open; workspaceId is malformed or belongs to another tenant.
Common situations: Client reused a stale workspaceId after leaving a workspace; multi-workspace client subscribing to all known workspaces without verifying membership; race between revocation and the realtime subscription.
Related errors
- unsupported_client_version
- space_access_denied
- doc_action_denied
- member_quota_exceeded
- authentication_required
AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12).
Data as JSON: /api/errors/0ca5eb676291180e.
Report an issue: GitHub.