Sprinter Docs

Multi-Tenant Workspaces

Create and switch between isolated organizations — each with their own data, members, roles, and agents. The URL is the sole source of truth for the active tenant, giving per-tab and per-device independence for free.

Multi-Tenant Workspaces

The platform supports fully isolated multi-tenant workspaces. Each tenant (organization) has its own records, data types, agents, navigation config, members, and API keys. Users can belong to multiple tenants and switch between them at any time.

Overview

Every database table is scoped by tenant_id. Row-level security (RLS) policies enforce this isolation — queries from one tenant cannot read or write another tenant's data, even for system admins.

The URL is the sole source of truth for the active tenant — see ADR-0003. Each request resolves tenant freshly from the URL path (/t/<slug>/…), so two tabs on different tenants cannot drift, and devices do not share tenant state.

The resolution pipeline:

  1. Client request — page URL is /t/<slug>/… (or a /api/… route called from a tenant page)
  2. Middleware (proxy.ts) extracts the slug via deriveTenantSlugFromRequest() and sets the x-tenant-slug header on the rewritten request
  3. PostgREST db-pre-request hook (private.set_tenant_context) reads the header, verifies membership in user_tenants, and pins a transaction-local app.tenant_id GUC
  4. RLS reads the GUC via get_active_tenant_id() — the JWT and profile are no longer consulted
  5. Application code uses getTenantContext() which reads the same header

If any step can't resolve a tenant, the GUC stays unset and RLS returns zero rows. Fail-closed by design.

How It Works

Creating a tenant

From Admin > Tenant > Create Tenant, provide a name and slug. The slug must be URL-safe ([a-z0-9][a-z0-9-]*). You become the admin of the new tenant automatically; navigate to /t/<slug>/dashboard to start working in it.

Switching tenants

Tenant switching is pure navigation. The sidebar user menu renders each tenant as a <Link href={tenantUrl(slug, "/dashboard")} />. Clicking it navigates to that tenant's URL; middleware sets the header; RLS reads the new tenant. No API call, no session mutation, no refreshSession().

Because there is no shared "active tenant" state on the server:

  • Two tabs can be on different tenants. Tab A on /t/oci/… and tab B on /t/ims/… each resolve their own context per request. Switching in one does not affect the other.
  • Two devices can be on different tenants. Device A on laptop stays on its tenant independent of device B on phone.

A last-tenant-slug cookie is written on each tenant navigation and used to redirect bare paths (e.g. /dashboard) to /t/<last-slug>/dashboard. This cookie is a UX hint only — it is never read for authorization.

Tenant-scoped URLs:

Any URL can be prefixed with /t/[tenantSlug]/ to force a specific workspace context. For example, /t/acme-corp/opportunity renders the opportunity list for acme-corp. The middleware intercepts these paths, rewrites them to the underlying route, and sets x-tenant-slug so getTenantContext() and the DB hook both resolve the correct tenant. Use tenantUrl(slug, path) from features/tenant/constants.ts to generate these URLs programmatically.

Membership and roles

Users join tenants via the user_tenants table with a role_id FK to the roles table.

App roleDB slugAccess level
ownersystem_adminFull access; can manage admins
admintenant_adminManage members, agents, data types
membereditor or memberCreate and edit records
viewerviewerRead-only access
guestguestRead-only; default on signup

New signups receive guest role in the default tenant. Promote users via Admin > Members.

Inviting members

Admins invite users by email from Admin > Members > Add Member. The invited user must already have an account. Select a role at invite time.

The "Allow access to public community" checkbox controls whether the user also receives a guest membership in the default tenant. It is unchecked by default so invited users land only in the org workspace.

Default tenant and community access

The default tenant (slug: "default", id: 00000000-0000-0000-0000-000000000000) is a shared workspace that cannot be deleted or renamed. Access to it is called community access and is explicitly controlled.

PathCommunity access
Self-signupAuto-enrolled in the default tenant as guest
Admin adds an existing user via Admin > MembersDefault tenant is not added unless "Allow access to public community" is checked
Admin invites a new user by emailDefault tenant is not added unless "Allow access to public community" is checked

In Admin > Members, each member row shows a globe icon with a toggle switch. Toggling controls a user_tenants row for the default tenant.

Per-Tenant Branding

Each tenant can customize the sidebar header identity via a BrandingConfig stored in tenant_settings under the key "branding".

// features/branding/types.ts
interface BrandingConfig {
  appName?: string;
  logoUrl?: string;
  logoIcon?: string;
  tagline?: string;
}

Resolution: getResolvedBranding() (features/branding/server/cached-queries.ts) cascades platform defaults with the tenant-level branding setting. Cached per request via React.cache().

API Reference

Server functions (features/tenant/context.ts)

FunctionDescription
getTenantContext()Resolves active tenant + user from the x-tenant-slug request header. Throws if no header or the user is not a member. Cached per request via React.cache().
getActiveTenantId()Returns the active tenant ID. Thin wrapper around getTenantContext().
getUserTenants()Returns all tenants the current user belongs to.
createTenant({ name, slug })Creates a new tenant and makes the caller its admin. Does NOT switch active tenant — caller navigates to tenantUrl(slug, ...).
addTenantMember({ tenantId, email, role })Adds an existing user by email. Throws if the user is not found.
removeTenantMember(tenantId, userId)Removes a user from a tenant.
getTenantMembers(tenantId)Returns all members with their roles. Uses admin client.
updateTenant({ tenantId, name?, logoUrl? })Updates tenant name or logo. Admin only. Cannot edit default tenant.
updateMemberRole({ tenantId, userId, role })Changes a member's role. Admin only; cannot self-demote.
ensureUserProvisioned()Creates a profile and, for users with no memberships, adds a default tenant membership. Called on first session.
getCommunityAccessMap(userIds)Batch check — returns a Set<string> of user IDs with default-tenant access.
grantCommunityAccess(userId)Upserts a guest membership in the default tenant.
revokeCommunityAccess(userId)Removes the default-tenant membership. No server-side "active tenant" mutation — the URL drives which tenant a user sees on their next navigation.

API routes

RouteAuthDescription
POST /api/tenantsAuthenticatedCreate a new tenant
POST /api/tenants/membersAdminAdd a member by email; supports includeCommunityAccess flag
PATCH /api/tenants/membersAdminChange a member's role
DELETE /api/tenants/membersAdminRemove a member
GET /api/tenants/members/community-accessAdminReturns { [userId]: boolean } map for all members
POST /api/tenants/members/community-accessAdminToggle community access: { userId, tenantId, enabled }
POST /api/auth/inviteAdminInvite a new user by email; supports includeCommunityAccess flag

Database

  • Hook: private.set_tenant_context() — runs on every PostgREST Data API request via ALTER ROLE authenticator SET pgrst.db_pre_request = 'private.set_tenant_context'. Reads x-tenant-slug, validates membership, pins app.tenant_id GUC.
  • Function: public.get_active_tenant_id() returns the GUC first, falls back to JWT app_metadata.active_tenant_id only for impersonated clients (createImpersonatedClient — API keys, background jobs).
  • Table: user_tenants(user_id, tenant_id, role_id) composite key. RLS policies on every tenant-scoped table call get_active_tenant_id() implicitly via authorize().

Design Decisions

URL is the sole source of truth for authorization. See ADR-0003. Session, cookie, and JWT tenant state all caused cross-tab and cross-device drift. Every major B2B SaaS (GitHub, Linear, Vercel, Slack, Notion, Stripe) uses the URL as the sole source of truth. The PostgREST db-pre-request hook extends that model all the way into RLS.

switchTenant is navigation, not an API. Removing the /api/tenants/switch route eliminates the shared-state mutation that caused cross-tab drift. Switching tenants is now <Link href={tenantUrl(slug, path)}> — zero server round-trip.

last-tenant-slug is a UX hint, never authorization. The cookie remembers the user's last-visited tenant so bare paths (/dashboard) can redirect to /t/<slug>/dashboard. It is never consulted for RLS.

Community access as a row, not a column. Community access is modeled as the presence or absence of a user_tenants row for the default tenant. Same RLS policies, same permission checks.

Workspace-scoped configuration uses the same 4-tier resolver. Settings such as agent_context in tenant_settings resolve via user > workspace > tenant > platform. See Workspaces for the full scope-resolver design and the list of the ten scope-aware tables.

DB trigger does only profile creation. handle_new_user() writes the profiles row and a default-tenant user_tenants row. It does not stamp auth.users.raw_app_meta_data.active_tenant_id — regular user JWTs no longer carry tenant state.

Self-signup still lands in the default tenant. ensureUserProvisioned() checks count on user_tenants for the user. Zero memberships means self-signup; one or more means the user arrived via an invite and already has a home.

Manual Test Script

Prerequisites

  • Logged in as admin or owner
  • A second user account available for invitation testing

Happy Path

  1. Create a new tenant

    • Go to Admin > Tenant > Create Tenant, name "Test Org" / slug "test-org"
    • Expected: Admin landing — then navigate to /t/test-org/dashboard via the sidebar switcher or a <Link>
  2. Verify data isolation

    • On /t/test-org/<any-list> — expected: empty
    • Navigate back to original tenant via the sidebar user menu (clicking a tenant sends you to its /dashboard)
    • Expected: Original records reappear
  3. Per-tab independence

    • Open a second tab at /t/test-org/dashboard
    • In the first tab, navigate to /t/<original-slug>/dashboard
    • Expected: Each tab stays on its own tenant; refreshing tab 2 does not flip it to tab 1's tenant
  4. Per-device independence

    • On a second browser/device, sign in and navigate to /t/test-org/dashboard
    • Expected: Laptop stays on its tenant regardless of which tenant the phone is showing
  5. Invite a member without community access

    • Go to Admin > Members > Add Member
    • Enter the second user's email; set role to "member"; leave "Allow access to public community" unchecked
    • Expected: User appears with correct role; community access toggle off
  6. Bare-path redirect

    • Navigate to /dashboard (no tenant prefix)
    • Expected: 307 redirect to /t/<your-last-visited-slug>/dashboard — uses the last-tenant-slug cookie

Edge Cases

  • Duplicate slug: Error on create — slug must be globally unique across all tenants
  • Tenant-scoped URL for a tenant you're not a member of: getTenantContext() throws "Not a member of tenant …"; RLS returns zero rows via the DB hook
  • Bare path with no cookie (first login): Falls back to first non-default membership (or default tenant if only membership)

Regression Checks

  • Data created in Tenant A is not visible in Tenant B
  • Two tabs on different tenants stay independent across navigation
  • Switching tenants is a <Link> click — no POST to /api/tenants/switch (endpoint deleted)
  • /t/<slug>/… URL correctly scopes data for the duration of the request
  • Guest users cannot see Admin tab; member/viewer users see "Access denied" if they navigate directly to /admin
  • Invited users do not appear in the default tenant unless "Allow access to public community" was checked
  • Self-signup users (zero prior memberships) do land in the default tenant
  • Revoking community access does not break the user's navigation — URL drives tenant, not server state

Troubleshooting

SymptomLikely CauseFix
"Not a member of tenant …"URL has a slug for a tenant the user doesn't belong toNavigate to one of the user's membership tenants via the switcher
Data from wrong tenant appearingExtremely unlikely under URL-truth. If seen, the PostgREST db-pre-request hook did not fire — check Supabase logs for hook errorsRegenerate migrations; verify pgrst.db_pre_request is set on authenticator role
Tenant-scoped URL not resolvingSlug does not match any tenantVerify the slug is correct; check the tenants table
Cannot edit tenant nameTrying to edit the default tenantOnly non-default tenants can be renamed
Community access toggle not visibleViewing members from the default tenant, or viewer/member roleToggle only appears for admins on non-default tenants
Bare path /dashboard shows wrong tenantlast-tenant-slug cookie points at an old tenantNavigate to the correct /t/<slug>/dashboard — cookie refreshes. Or clear the cookie.
Realtime subscription empty for the correct tenantRealtime doesn't run the db-pre-request hook; uses JWT pathRealtime RLS still relies on JWT app_metadata.active_tenant_id for impersonated flows — not a drift issue for user sessions (no claim written). Use tenant-scoped channel names. Follow-up: migrate realtime policies to membership-based.

Regression coverage

proxy.ts middleware has integration test coverage in proxy.test.ts protecting every guard that shipped in response to the 2026-04-16 cross-tenant data-leak incident. Each guard has a named test — if a future refactor removes a guard, the test name explains why it existed:

  • Prefetch / RSC cookie guard — Next.js prefetches /t/<other-slug>/* links from the sidebar tenant switcher. The guard skips the last-tenant-slug cookie write when next-router-prefetch, rsc, or purpose: prefetch headers are present. Without the guard, each prefetch flipped the cookie on the current tab. Under the URL-truth model the cookie is no longer an authorization input, but stale prefetch-flips still misroute bare-path navigations (/dashboard → wrong tenant).
  • Cookie attribute preservation — Cookies forwarded through a rewrite response MUST be passed as full cookie objects, not set(name, value). The attribute-stripping variant caused refreshed auth tokens to land with wrong attributes, leading to intermittent auth failures.
  • API Referer tenant inference — API routes called from a tenant-scoped page carry x-tenant-slug header forward via Referer parsing. The header is the sole input PostgREST's db-pre-request hook reads to pin app.tenant_id for that request, so missing it causes the data API to fall back to the impersonated-JWT path or fail closed.

See proxy.test.ts and the inline comments in proxy.ts for the full rationale.

On this page