Design and Architecture

Organization Activation and Routes

How organization activation gates mutation APIs and why org-scoped APIs use route org IDs.

Organization Activation and Routes

Zeeq treats organization activation as a server-side authorization precondition for organization-scoped state changes. An organization is active when Organization.ActivatedAtUtc is set and Organization.DisabledAtUtc is not set.

New organizations are activated at creation time. Existing non-disabled organizations are backfilled with activated_at_utc = created_at_utc during the activation migration. Disabled organizations remain inactive.

Route Shape

Organization-scoped APIs use /api/v1/orgs/{orgId}/....

The route orgId must match the active organization ID carried by the authenticated cookie. The route/cookie consistency filter runs before active-state checks:

ConditionResult
Missing route orgId400 Bad Request
Missing cookie organization claim401 Unauthorized
Route orgId does not match the cookie organization403 Forbidden
Route orgId matches the cookie organizationThe request continues

Do not accept organization scope only through query string parameters for new org-scoped APIs. If an endpoint needs organization context, put it in the route unless it is an auth bootstrap or activation-adjacent flow that deliberately has no active organization yet.

Protected Routes

Mutation routes under /api/v1/orgs/{orgId}/... require an active organization before they can mutate state. This includes:

  • Organization profile updates.
  • Member role changes and member removal.
  • Organization invitation create and cancel.
  • LLM settings, key management, and provider tests.
  • Code-review manual requests, organization settings, reviewer agents, and repository review configuration.
  • GitHub repository mapping create, update, and disable.
  • User-owned client credentials and API tokens under the active organization route.

GET /api/v1/me uses the current organization from the cookie instead of a route parameter. It also requires that current organization to be active so the app shell can redirect inactive sessions consistently.

Exempt Routes

These routes intentionally do not use the active-organization filter:

  • Organization creation and slug checks.
  • Login, logout, OAuth callbacks, and dynamic auth bootstrap flows.
  • GitHub App install and callback routes.
  • POST /api/v1/me/invitations/{id}/accept and /decline.

Invitation accept and decline are activation-adjacent. A user may need to accept an invitation into an active organization while their current organization is inactive or unset, so those handlers validate the invitation transition directly instead of depending on the current organization activation filter.

Inactive Behavior

When the active-organization filter finds an inactive organization, it returns a redirect to /login?inactiveOrg=true. The frontend Kubb fetch transport handles redirected responses before JSON parsing and navigates the browser to the login page. The login view uses the inactiveOrg=true page state to render the inactive-organization notice below the OAuth login card.

The redirect is intentional for browser callers. API code should not treat inactive-organization responses as ordinary JSON error bodies.

Cache Window

The active-state lookup uses HybridCache with a 30-second expiration and local expiration. Within that window, repeated endpoint-filter checks can reuse the cached OrganizationActivationState projection instead of loading the full organization row.

The cache is a short consistency window. Activation or disable changes may take up to 30 seconds to be reflected by filters on every process.

Implementation Notes

The filters live in Zeeq.Core.Identity.Organizations:

  • RequireRouteOrganizationMatchesCookieFilter
  • RequireActiveOrganizationFilter
  • RequireActiveCurrentOrganizationFilter

Endpoint groups attach them through RequireRouteOrganizationMatchesCookie(), RequireActiveOrganization(), and RequireActiveCurrentOrganization(). Prefer applying the route/cookie filter at the route group and the active filter only to the mutation handlers that need it.