Skip to content

FM-045 — Cross-Workspace Personal Surfaces Hit requireAppEnabled 403s

App gates applied per-workspace, but rollup surfaces (Command Center, Home) legitimately span workspaces. Any disabled app caused 403 cascade.

FieldValue
SeveritySEV-2 (cross-workspace rollups silently broken)
CategoryAPI Authorization / Cross-workspace
Date2026-05-04
StatusArchived

Cross-workspace rollups (Command Center, Home focus list, “Also needs attention”, inline task panel) silently fail with 403s for tasks in workspaces where the relevant app is disabled. Presents as “Could not load this task”, “Failed to complete a task”, or AppDisabledPage on navigate.

Per-workspace app gates (requireAppEnabled('tasks')) are applied at the route mount, but rollup surfaces are cross-workspace personal-productivity views. The moment a user has an app off in any workspace, every endpoint backing the rollup 403s for those rows.

The class is bigger than tasks — any future cross-workspace rollup that hits per-workspace-app-gated endpoints will hit the same wall.

Server: factor personal-edit endpoints (GET by id, PATCH by id, archive, lifecycle mutations) out of the gated default router into a sibling ungated “personal lifecycle” router. Mount on same path before gated mount. Multi-tenancy stays enforced by organizationContext membership and id+organizationId scoping.

Client: rollup row’s “Open in app” affordance must check the target workspace’s app-enabled state, not current. Use useAppAccessForOrg(targetWorkspaceId) and hide the link when disabled.

  • Before adding requireAppEnabled to single-resource GET/PATCH/DELETE: ask “Does any cross-workspace surface call this for items from other workspaces?” If yes, factor onto ungated lifecycle router.
  • Before adding “Open in app” navigation from a cross-workspace rollup: gate on useAppAccessForOrg(targetWorkspaceId).access[app]. Default to hidden, not to broken navigation.
  • Search proactively: grep server/routes.ts for requireAppEnabled calls on tasks / goals / projects / calendar — each line is a candidate.