Multi-tenancy

Every tenant-scoped row carries a subscriptionId. Isolation is enforced in application code at the repository layer, not by PostgreSQL row-level security.

Resolving the active workspace

resolveSubscription() in lib/auth/subscription.ts is the single entry point. It returns a discriminated union so callers cannot forget the failure case:

type SubscriptionResult =
  | { success: true;  context: { userId: string; subscriptionId: string; email?: string } }
  | { success: false; error: string; status: number }

Resolution order:

  1. Authenticate the Supabase user; no user → 401 Unauthorized.

  2. Read the requested workspace from the x-subscription-id header, falling back to the active_subscription_id cookie.

  3. Look up the Profile for supabaseUserId; none → 403 No profile found for user.

  4. If a workspace was requested, verify a SubscriptionUser row exists for (subscriptionId, profileId); otherwise → 403 Forbidden: not a member of this subscription.

  5. If none was requested, default to the workspace the user owns, else their oldest membership; none at all → 403 No subscription found for user.

  6. Stamp Sentry with the user and a subscriptionId tag, then return the context.

requireSubscription() is the throwing variant, raising SubscriptionError(message, status).

Setting the active workspace

The cookie is only ever written by the server:

  • POST /api/subscriptions/active sets active_subscription_id on the response.

  • POST /api/auth/signout clears it.

  • Client code never writes the cookie directly — the sidebar workspace switcher posts to the endpoint and then invalidates React Query caches.

GET /api/me returns the current user, their profile within the active workspace, the list of workspaces they belong to, and active_subscription_id. The dashboard will not render until this call resolves.

Roles

SubscriptionUser.role is owner, admin or member.

Action owner admin member

Read workspace data

✓

✓

✓

Add a member

✓

✓

—

Change a member’s role

✓

✓ (not to/from owner)

—

Remove a member

✓

✓ (not an owner)

—

Assign the owner role

✓

—

—

Only owners may remove owners or grant ownership. Enforced in the /api/subscriptions/members routes — see Auth and workspace endpoints.

Scoped models

Almost every domain model carries subscriptionId: Campaign, CampaignTarget, Call, CallAiAgentAnalysisResult, PhoneNumbers, AiAgent, AiAgentVersion, Configs, AgentTestCase, Activity, Seller, Contact, EmailCampaignTarget, EmailVariation, Domain, EmailSendingDomain, TargetPhoneNumbers, TargetEmails, Emails, SalesRep, FollowUpTask, SequenceTemplate, ContactSequence, BlockedPhoneNumbers.

Notable exceptions and their reasons:

Model Why it is not scoped

Profile

Identity is global; workspace membership is expressed by SubscriptionUser

WebhookEvent

Raw provider payload captured before a tenant is known

SequenceTemplateStep, ContactSequenceStep

Scoped transitively through their parent

Call.subscriptionId

Nullable, because Nooks calls may arrive before a workspace match

AiAgent.subscriptionId

Nullable, to allow shared/global agents

Where a model’s subscriptionId is nullable, queries must still constrain it — use lib/utils/call-subscription-scope.ts for calls, which builds the correct where fragment.

Uniqueness is per workspace

Composite unique constraints encode the tenancy boundary, so the same email or phone number can exist in two workspaces:

  • Contact: (subscriptionId, email), (subscriptionId, phone), (subscriptionId, hubspotContactId), (subscriptionId, supplierKey)

  • TargetEmails: (subscriptionId, email)

  • SalesRep: (subscriptionId, email)

  • Activity: (subscriptionId, externalId)

  • Emails: (subscriptionId, externalId)

  • Domain: (subscriptionId, name)

TargetPhoneNumbers.phoneNumber and PhoneNumbers.phoneNumber are globally unique — a carrier number can only belong to one workspace.

Review checklist

  • Did the route call resolveSubscription() and handle success: false?

  • Does every where in the repository include subscriptionId?

  • For nullable-scope models, is the null case handled deliberately rather than by accident?

  • Does a new unique constraint include subscriptionId unless the value is globally unique?