{"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/26c515e050211269e911f7d9cfe162a26c83ed98/packages/backend/server/src/core/quota/realtime.ts#L117-L148","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nawait this.assertWorkspace(user.id, payload.workspaceId);\n\n// after\nconst role = await this.models.workspaceUser.getActive(payload.workspaceId, user.id);\nif (!role) throw new SpaceAccessDenied({ spaceId: payload.workspaceId });","handlingStrategy":"validation","validationCode":"const role = await models.workspaceUser.getActive(workspaceId, user.id);\nif (!role) {\n  // do not subscribe; tell the client to leave the workspace\n  socket.emit('workspace.membership.lost', { workspaceId });\n  return;\n}","typeGuard":"// n/a — runtime membership lookup, not a type narrowing","tryCatchPattern":"try {\n  await this.assertWorkspace(user.id, payload.workspaceId);\n} catch (e) {\n  if (e instanceof SpaceAccessDenied) {\n    // unsubscribe the socket and notify the client\n    await client.leave(realtimeWorkspaceQuotaStateRoom(payload.workspaceId));\n    return;\n  }\n  throw e;\n}","preventionTips":["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."],"tags":["realtime","permissions","quota","websocket","nestjs"],"backgroundTag":null,"analyzedSha":"26c515e050211269e911f7d9cfe162a26c83ed98","analyzedAt":"2026-08-12T13:15:16.447Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}