URL-based organization tenancy, slug extraction, access guards, and tenant navigation.
CentraKit encodes the active organization directly in the URL. There is no current-organization cookie, hidden precompute segment, or in-memory tenant switch.
Tenant-scoped routes sit under [orgSlug] inside the authenticated SaaS app group:
app/[locale]/[brand]/(app)/
layout.tsx
page.tsx
[orgSlug]/
layout.tsx
page.tsx
portal/
users/ teams/ roles/ workflows/ billing/ usage/ settings/System routes such as /admin/*, /profile, /organizations/new, and /auth/* live outside the [orgSlug] segment because they are not scoped to a tenant.
[locale] and [brand] are hidden root segments: the proxy rewrites onto them, so neither appears in a public URL.
One deployment serves several product brands, and each brand has one app host, such as app.centrakit.com or app.mechup.com. The brand registry in packages/foundation/src/brands.ts maps hosts to brands.
Map and inserts it after the locale in the rewrite. It does no fetch, database, cookie or header work for the brand.getBrand() from apps/saas/lib/brand/current-brand.ts, which uses the brand root param. Server Actions and route handlers have no root params, so getBrand() falls back to the request host there, and getRequestBrand() reads the host directly.organizations.brand. Creating an organization takes the brand from the request host, never from user input.brandServesOrganization() in packages/foundation/src/brands.ts holds the rule; the org switcher, resolvePostAuthPath and the app shell lists filter with it, and the slug gate calls notFound() when it refuses. The chrome always follows the host, so a MechUp organization opened on the CentraKit host keeps the CentraKit theme./admin/organizations, with a brand column and filter, and can move an organization to another brand. A database trigger refuses brand changes from anyone except platform admins and the service role.[orgSlug] under app/[locale]/[brand]/(app)/[orgSlug]/....extractOrgSlug(pathname) in apps/saas/lib/organizations/url.ts strips reserved root segments, validates the slug shape, and lowercases it when callers need pathname parsing (for example navigation helpers).await params at the page or layout root. Access gates and data loaders run inside Suspense islands and receive orgSlug via params.then(({ orgSlug }) => …).getCurrentAppAccess(orgSlug) / requireOrganizationAccess({ orgSlug, permission? }) resolve membership by the explicit slug. Pass null only for session-only chrome (/profile, /admin, /organizations). Unknown slugs return notFound() rather than redirecting to a canonical path.requireOrganizationAccess({ orgSlug, permission? }) runs inside tenant-page Suspense islands. Callers pass the slug from route params; the guard checks membership and optionally enforces a permission using the loaded organization permission set.requireCustomerPortalAccess({ orgSlug }) allows organization members and contact-linked non-members into /{orgSlug}/portal. Unknown organizations 404. Portal record lists remain scoped to linked customer_contacts (empty for org members without a contact link).requireSystemAccessAdmin() gates /admin/* pages.AppShell mounts once in (app)/layout.tsx. [orgSlug]/layout.tsx runs OrganizationSlugAccessGate in Suspense so unknown/non-member slugs still notFound() without blocking cold chrome paint (may be HTTP 200 with the not-found UI after streaming). /profile, /admin and /organizations share that same shell without a slug gate. Sidebar nav hrefs still re-derive the active organization from the pathname on the client.The proxy enforces the coarse authenticated-app boundary. Page guards enforce organization membership, customer linkage, and permissions. Staff membership remains the default access kind when staff and customer access overlap, but the compact app-shell organization payload preserves both available access kinds.
apps/saas/lib/navigation.ts defines nav sections with a scope and optional permission./settings; resolveNavHref prefixes them with /{orgSlug} at render time.Link-based, and the link decides between staff and portal before navigation through organizationHomeHref: staff go to /{slug} and customer-only users go to /{slug}/portal. The cached customer access gate stays as the fallback for typed or bookmarked URLs./{orgSlug}/portal/** shows Customer navigation and /admin/** shows Admin navigation. The organization switcher is the only place to change mode. An organization where the user is staff opens a submenu with Organization (/{orgSlug}) and Portal (/{orgSlug}/portal); on desktop the submenu opens on hover and clicking the organization itself opens /{orgSlug}, a customer-only organization links straight to its portal, and system admins get a last Admin item that opens /admin. Route guards remain authoritative.createOrganizationAction returns { organizationId, slug } and redirects to /{slug}.deleteOrganizationAction returns { nextOrganizationSlug } and redirects to the next available organization or /organizations/new.apps/saas/tests/rbac-url.test.ts covers extractOrgSlug.tests/integration/saas/proxy.test.ts covers proxy redirects, public-path behavior and the host-to-brand prefix.tests/integration/saas/customer-portal-guards.test.ts covers customer-only, staff portal access, and unknown-organization portal access.tests/integration/saas/app-organization-access.test.ts covers merged staff/customer access and route-specific shell context.apps/saas/tests/app-navigation-mode.test.ts covers route-derived and available navigation modes.Last updated on