{"record":{"id":"0ca5eb676291180e","repo":"toeverything/AFFiNE","slug":"space-access-denied-0ca5eb","errorCode":"space_access_denied","errorMessage":"You do not have permission to access Space ${spaceId}.","messagePattern":"You do not have permission to access Space (.+?)\\.","errorType":"exception","errorClass":"SpaceAccessDenied","httpStatus":403,"severity":"error","filePath":"packages/backend/server/src/core/quota/realtime.ts","lineNumber":135,"sourceCode":"    );\n  }\n\n  @OnEvent('workspace.quota_state.changed', { suppressError: true })\n  async onWorkspaceQuotaStateChanged({\n    workspaceId,\n  }: Events['workspace.quota_state.changed']) {\n    this.publisher?.publish(\n      'workspace.quota-state.changed',\n      { workspaceId },\n      { changed: true },\n      { room: realtimeWorkspaceQuotaStateRoom(workspaceId) }\n    );\n  }\n\n  private async assertWorkspace(userId: string, workspaceId: string) {\n    const role = await this.models.workspaceUser.getActive(workspaceId, userId);\n    if (!role) {\n      throw new SpaceAccessDenied({ spaceId: workspaceId });\n    }\n  }\n\n  private serializeState<T extends Record<string, unknown>>(state: T) {\n    return Object.fromEntries(\n      Object.entries(state).map(([key, value]) => [\n        key,\n        typeof value === 'bigint' ? Number(value) : value,\n      ])\n    );\n  }\n}\n","sourceCodeStart":117,"sourceCodeEnd":148,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/core/quota/realtime.ts#L117-L148","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Confirm the workspaceId is one of the user's active workspaces before subscribing.","Re-authenticate and re-open the realtime session after membership changes.","Check that a workspaceUser row exists and is active for the pair."],"exampleFix":"// before\nawait realtime.subscribe(realtimeWorkspaceQuotaStateRoom(workspaceId));\n\n// after\nconst role = await models.workspaceUser.getActive(workspaceId, userId);\nif (!role) throw new SpaceAccessDenied({ spaceId: workspaceId });\nawait realtime.subscribe(realtimeWorkspaceQuotaStateRoom(workspaceId));","handlingStrategy":"validation","validationCode":"const role = await models.workspaceUser.getActive(workspaceId, userId);\nif (!role) {\n  throw new Error(`user ${userId} is not a member of ${workspaceId}`);\n}","typeGuard":"const isSpaceAccessDenied = (e: unknown): e is SpaceAccessDenied =>\n  e instanceof SpaceAccessDenied;","tryCatchPattern":"try {\n  await quotaRealtime.publish(event);\n} catch (e) {\n  if (e instanceof SpaceAccessDenied) {\n    // resync membership, then drop the event silently\n    return;\n  }\n  throw e;\n}","preventionTips":["Validate workspace membership before opening quota realtime rooms.","Tear down realtime subscriptions as soon as the user's workspace list changes."],"tags":["permissions","realtime","quota","workspace"],"backgroundTag":"permission-denied","analyzedSha":"b4c8548c09da21b2898443559a5b846f0ccf5dd8","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}