Skip to main content

Architecture overview

This page explains the portal chrome every other flow runs inside: the route tree and guard nesting (src/app/routes.tsx), the two layout shells (src/components/layout/app-sidebar.tsx, src/components/layout/admin-sidebar.tsx), the three reads the shell makes before any page's own queries, and the global wiring (src/main.tsx). Per-flow mechanics live elsewhere: Authentication architecture covers the guard/token details and the post-auth landing matrix, Onboarding architecture the wizard and its state gate, Link creation pipeline the link write path. Operational traps for testing the shell live in the test-stack runbook.

Source-checked against optolink-portal @ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea and optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2, 2026-10-07 (FLOW-003 harvest; B-017 re-verification pass). No live re-audit was recorded for this flow; open questions are collected in docs/FLOW-003.md §11.

Route tree​

src/app/routes.tsx nests guards around layout routes:

portal pages RequireAuth → RequireOrg → AppLayout
admin pages RequireAuth → RequireOrg → RequireAdmin → AdminLayout
/login/*, /register/* AuthRedirect (splats, cover Clerk's step sub-URLs)
/onboarding RequireAuth only
* (splat) NotFoundPage — ungated, public
/ref-page ungated dev design reference

The /login/* and /register/* splats exist so Clerk's multi-step sub-URLs resolve inside the SPA instead of falling into the 404 catch-all. They are declared before the splat, which is what gives them priority.

Back-compat redirects are router loaders, so they fire before any guard:

  • /getting-started → /dashboard
  • /analytics/templates → /analytics/breakdowns?by=template (query params preserved)
  • /templates, /templates/create, /templates/:id, /templates/:id/edit → /settings?tab=templates variants

Route path constants live in src/lib/constants.ts (ROUTES, API_PATHS).

Guard chain​

Files: src/lib/route-guards.tsx (RequireAuth, RequireOrg, AuthRedirect, GuardErrorScreen at L38) and src/lib/admin-route-guard.tsx (RequireAdmin). Roles always come from Clerk session claims via useAuth; nothing reads the database to authorize.

  • RequireAuth waits for Clerk to resolve (branded splash), then bounces signed-out users to /login?redirect=<path+search>. The redirect value is validated by safeRedirectPath (src/lib/utils.ts): //host and absolute-URL forms are refused. Where each role lands after sign-in is FLOW-001's matrix; this page doesn't restate it.
  • RequireOrg consumes useNeedsOnboarding (src/hooks/use-onboarding.ts L73-93). It trips when the user has an org, state has no bypass, and app config is missing or no plan is selected, redirecting to /onboarding. On a state-read error it renders GuardErrorScreen (error banner + Retry) and never redirects. That's the FB-2 invariant: a failed read must not fabricate routing.
  • RequireAdmin admits only the platform ADMIN persona (ops-org membership).

/onboarding sits under RequireAuth only, so it stays reachable in the orgless state by design.

The two layouts​

Both shells share the same bones:

  • A "Skip to main content" link as the first focusable element.
  • useRouteFocus (src/hooks/use-route-focus.ts) focuses #main-content (tabIndex -1, preventScroll) on pathname change only, skipped on initial mount. The focus move is the screen-reader announcement; there is no live region.
  • A <main> capped at max-w-[1440px] mx-auto, with min-w-0 on both main columns (FLOW-004 FB-3, mobile overflow).

AppLayout redirects admins. A user whose Clerk role is ADMIN is sent to /admin unless impersonatedOrgId is set in src/stores/admin-passthrough-store.ts (the only Zustand store; src/stores/sidebar-store.ts is deleted). While impersonating, the header shows the impersonated org's name and hides the member count (UX-4: Clerk's active org is still the admin's own, so the real count would be wrong).

AdminLayout owns the impersonation-exit handshake (FB-4). The exit is deliberately two steps to defeat a redirect race. The banner (passthrough-banner.tsx) navigates to /admin/organizations with replace: true and location state { passthroughExit: true } while the store is still set; once AdminLayout mounts, a mount-only effect clears the passthrough store and calls queryClient.clear(). Clearing the store first would let AppLayout's admin redirect win the race and land on /admin. Any refactor must keep navigate-then-clear. Side effect: queryClient.clear() leaves every cached query cold, so pages show loading states immediately after Exit.

Content-scroll asymmetry. The org-side <main> deliberately has no overflow-auto: an overflow ancestor becomes the scrollport and kills position: sticky descendants (the link-preview rail). The admin-side <main> still carries overflow-auto; whether admin pages have sticky descendants it could break is unverified (docs/FLOW-003.md §11). Don't "normalize" the org side to match.

  • Nav data. Org: six items in NAV_ITEMS (Home, Analytics, Links, Custom domains, API Keys, Settings). Admin: eight in ADMIN_NAV_ITEMS (Dashboard, Organizations, Users, Domains, Audit Log, System, Billing, Sandbox); a devOnly filter drops Sandbox when import.meta.env.MODE === "production". There is no Getting Started item and no header section label; both were removed, though the FLOW-003 fix-pass record still claims a routeSection() label (commit fbdd304 dropped it).
  • Active state. SidebarNavLink (sidebar-nav-link.tsx) derives isActive from useLocation().pathname (end prop ? exact match : exact-or-prefix), feeds it to SidebarMenuButton as data-active, and calls setOpenMobile(false) on every click so the mobile drawer closes (a no-op on desktop). The /dashboard and /admin items require end; without it the parent item lights up on every child page.
  • Collapse persistence. SidebarProvider (src/components/ui/sidebar.tsx) writes the sidebar_state cookie (path=/; max-age=604800) on every toggle (L99); getSidebarDefaultOpen() reads it at mount (L40-46, false = collapsed), and both layouts pass it as defaultOpen.

The three shell reads​

Before any page's own queries, the shell issues up to three GETs:

ReadCallerHookRoles
GET /portal/onboarding/stateRequireOrguseNeedsOnboardingany org role (no @Roles)
GET /portal/team/membersHeaderuseMembers (use-team.ts L17)any org role
GET /portal/billing/subscriptionHeader + UserMenuuseSubscription (use-subscription.ts L25, query key ["subscription"])any org role

All three controllers mount ClerkAuthGuard → RolesGuard → RateLimitGuard and none declares @Roles, so every org role can read them. GET /portal/team/members (src/team/team.controller.ts L58) proxies clerkBackend.listMembers; a null subscription means Starter with no record (src/portal/portal-billing.controller.ts L544). The old sidebar gate (GET /portal/links?limit=1 on every page) is gone: no useLinks import remains in any layout file, though the stale comment at src/hooks/use-links.ts L32 still mentions "the sidebar gate".

Upgrade-pill decision (showUpgrade in Header): render only when the subscription query is settled without error, status is not PAST_DUE, and isSubscribed(subscription) is false. isSubscribed (src/lib/plans.ts L317) is status === "ACTIVE" && plan !== "STARTER". Two suppressions are deliberate:

  • Query error hides the pill (FB-2, FLOW-012): an errored read is not "not subscribed".
  • PAST_DUE hides it (FLOW-015 UX-1): don't offer Upgrade to an org whose real problem is a failing payment.

The pill is also hidden below sm (640px) because it forced a 390px overflow (FLOW-013 UX-5); mobile keeps the upgrade path via Settings → Billing.

Error posture and query defaults​

There is no error boundary anywhere in the app. Shell reads therefore degrade silently: a failed members read renders a blank count, a failed subscription read hides the Upgrade pill, no toast, no banner. The one surfaced failure is the guard reads: RequireOrg/AuthRedirect render GuardErrorScreen with Retry and never redirect.

src/lib/query-client.ts defaults: staleTime 30s, gcTime 5m, retry 1, refetchOnWindowFocus: false; mutations retry 0. Header data is therefore at most 30s stale and never refetches on focus.

Provider stack​

src/main.tsx mounts, in order: StrictMode → ThemeProvider (class strategy, defaultTheme="dark", enableSystem={false}) → ClerkProvider (routerPush/routerReplace wired to the router, appearance from src/lib/clerk-appearance.ts, signInUrl/signUpUrl) → QueryClientProvider → ClerkTokenBridge → TooltipProvider → RouterProvider.

Env consumed at this level: VITE_CLERK_PUBLISHABLE_KEY (required), VITE_API_BASE_URL (optional, default ""), VITE_SDK_DOCS_URL / VITE_PRODUCT_DOCS_URL (default to the public docs site), and VITE_CLERK_OPS_ORG_ID (ops-persona detection in src/hooks/use-auth.ts).

Why the nav is not role-filtered​

Viewer and Analyst see the same six items as Owner. This is safe by construction: every nav destination's GET is role-ungated server-side, and writes are gated at page level with hasMinimumRole (src/lib/roles.ts, ladder viewer < analyst < developer < admin < owner; platform ADMIN = ops-org membership). Filtering the sidebar would add a second role source without removing the page-level checks.

Gotchas​

  • The stale comment at src/hooks/use-links.ts L32 still references the removed sidebar gate.
  • docs/screenshots/FLOW-003/* show the pre-fix shell (placeholder brand, no active highlight, silent 404). Don't use them to illustrate current behavior.
  • The light theme has had no dedicated contrast pass; the audit checked dark only.
  • E2E: focus jumps to #main-content on every SPA navigation, so assertions that assume focus stays on the clicked nav item fail.