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 assertWorkspace in the quota realtime service (packages/backend/server/src/core/quota/realtime.ts:135). Before touching workspace quota-state realtime rooms it loads models.workspaceUser.getActive(workspaceId, userId); when no active membership row exists it throws SpaceAccessDenied to keep quota events workspace-private.

Solutions

  1. Confirm the workspaceId is one of the user's active workspaces before subscribing.
  2. Re-authenticate and re-open the realtime session after membership changes.
  3. Check that a workspaceUser row exists and is active for the pair.

Example fix

// before
await realtime.subscribe(realtimeWorkspaceQuotaStateRoom(workspaceId));

// after
const role = await models.workspaceUser.getActive(workspaceId, userId);
if (!role) throw new SpaceAccessDenied({ spaceId: workspaceId });
await realtime.subscribe(realtimeWorkspaceQuotaStateRoom(workspaceId));
Defensive patterns

Strategy: validation

Validate before calling

const role = await models.workspaceUser.getActive(workspaceId, userId);
if (!role) {
  throw new Error(`user ${userId} is not a member of ${workspaceId}`);
}

Type guard

const isSpaceAccessDenied = (e: unknown): e is SpaceAccessDenied =>
  e instanceof SpaceAccessDenied;

Try / catch

try {
  await quotaRealtime.publish(event);
} catch (e) {
  if (e instanceof SpaceAccessDenied) {
    // resync membership, then drop the event silently
    return;
  }
  throw e;
}

Prevention

When it happens

Trigger: Subscribing to or publishing workspace.quota-state realtime events for a workspace where the user has no active member role: removed member with an open socket, wrong workspaceId, or a test firing events for an arbitrary workspace.

Common situations: User removed from workspace while their realtime connection is still alive, stale client caching an old workspaceId, or multi-workspace clients mixing ids after switching workspaces.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


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

Appendix: 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 b4c8548c09)