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:
-
Authenticate the Supabase user; no user →
401 Unauthorized. -
Read the requested workspace from the
x-subscription-idheader, falling back to theactive_subscription_idcookie. -
Look up the
ProfileforsupabaseUserId; none →403 No profile found for user. -
If a workspace was requested, verify a
SubscriptionUserrow exists for(subscriptionId, profileId); otherwise →403 Forbidden: not a member of this subscription. -
If none was requested, default to the workspace the user owns, else their oldest membership; none at all →
403 No subscription found for user. -
Stamp Sentry with the user and a
subscriptionIdtag, 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/activesetsactive_subscription_idon the response. -
POST /api/auth/signoutclears 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 |
✓ |
— |
— |
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 |
|---|---|
|
Identity is global; workspace membership is expressed by |
|
Raw provider payload captured before a tenant is known |
|
Scoped transitively through their parent |
|
Nullable, because Nooks calls may arrive before a workspace match |
|
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 handlesuccess: false? -
Does every
wherein the repository includesubscriptionId? -
For nullable-scope models, is the null case handled deliberately rather than by accident?
-
Does a new unique constraint include
subscriptionIdunless the value is globally unique?