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]/(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.
[orgSlug] under app/[locale]/(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, /organizations, and
/feedback 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. Choosing an organization navigates to /{slug}./{orgSlug}/portal/** shows Customer navigation. Organization / Customer / Admin mode switches in the user menu are in-memory presentation previews; clicking a nav item navigates to the real route. Admin mode remains system-admin-only. 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.tests/unit/core/rbac-url.test.ts covers extractOrgSlug.tests/integration/saas/proxy.test.ts covers the proxy slug header and public-path behavior.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.tests/unit/saas/app-navigation-mode.test.ts covers route-derived and available navigation modes.