One set of guard functions covers feature flags, plan entitlements, and ABAC permissions. They compose in beforeLoad, throw typed errors that RouteErrorComponent maps to UX, and read from the same session context that Storybook mocks globally. Nav visibility and route access use the same underlying checks — they can’t drift.

Three access dimensions

Dimension Check Source
Feature flag getFeatureFlag(key) LaunchDarkly / localStorage override
Entitlement hasEntitlement(plan, name) Plan record from API
ABAC permission hasAbacStatement(perms, stmt) Permissions record from API

Each guard is a one-liner that throws a typed error on denial:

function requireFeatureFlag(key: string): void {
  if (!getFeatureFlag(key)) throw new FeatureFlagDisabledError(key);
}

function requireEntitlement(client: QueryClient, name: string): void {
  const plan = client.getQueryData<Plan>(planQueryKey());
  if (!hasEntitlement(plan, name)) throw new NotInPlanError(name);
}

function requireAllAbac(client: QueryClient, ...stmts: AbacStatement[]): void {
  const perms = client.getQueryData<Perms>(permissionsQueryKey());
  if (!stmts.every(s => hasAbacStatement(perms, s))) throw new AccessDeniedError();
}

function requireAnyAbac(client: QueryClient, ...stmts: AbacStatement[]): void {
  const perms = client.getQueryData<Perms>(permissionsQueryKey());
  if (!stmts.some(s => hasAbacStatement(perms, s))) throw new AccessDeniedError();
}

Convenience wrappers for common CRUD patterns:

const requireCanCreate = (client, area) => requireAnyAbac(client, { action: "CREATE", area });
const requireCanRead   = (client, area) => requireAnyAbac(client, { action: "READ",   area });
const requireCanUpdate = (client, area) => requireAnyAbac(client, { action: "UPDATE", area });
const requireCanDelete = (client, area) => requireAnyAbac(client, { action: "DELETE", area });

Route usage

export const Route = createFileRoute("/rules")({
  beforeLoad: ({ context: { queryClient } }) => {
    requireFeatureFlag("release-rules");     // must have flag
    requireEntitlement(queryClient, "rules"); // must have plan entitlement
    requireCanRead(queryClient, "rules");    // must have ABAC read permission
  },
});

Sequential calls are AND. The first failure throws; subsequent checks are skipped.

In-component usage

Same primitives, read synchronously from cache, used for conditional rendering:

function RulesToolbar({ queryClient }: { queryClient: QueryClient }) {
  const perms = queryClient.getQueryData<Perms>(permissionsQueryKey());
  const canCreate = hasAbacStatement(perms, { action: "CREATE", area: "rules" });

  return (
    <div>
      {canCreate && <Button>Create rule</Button>}
    </div>
  );
}

The same underlying functions gate nav visibility and route access:

// nav config
{
  label: "Rules",
  path:  "/rules" satisfies AppRoutePath,
  hidden: !getFeatureFlag("release-rules") || !hasEntitlement(plan, "rules"),
}

// route beforeLoad
requireFeatureFlag("release-rules");
requireEntitlement(queryClient, "rules");

If you hide the nav link behind a flag, the route guards the same flag. Structural consistency — not a convention you have to remember.

Storybook: global auth defaults

Every story runs with full admin access and all flags enabled by default, wired in preview.tsx:

// .storybook/preview.tsx
const mockSession: Session = {
  accountId:    "test-account",
  featureFlags: allFlagsOn(),   // every flag returns true
  plan:         fullPlan(),     // every entitlement included
  permissions:  fullAdmin(),    // every ABAC statement allowed
};

const preview: Preview = {
  decorators: [
    (Story) => (
      <SessionProvider value={mockSession}>
        <Story />
      </SessionProvider>
    ),
  ],
};

Stories never hit real auth. The default session lets every component render its “authorized” state. Specific stories override only the dimensions they’re testing.

Storybook: per-story overrides

// Read-only view
export const ReadOnly: Story = {
  decorators: [
    withSession({
      permissions: noWritePermissions("rules"),
    }),
  ],
};

// Behind a flag
export const FeatureFlagOff: Story = {
  decorators: [
    withSession({
      featureFlags: { "release-rules": false },
    }),
  ],
};

// Non-plan account
export const NotEntitled: Story = {
  decorators: [
    withSession({
      plan: planWithout("rules"),
    }),
  ],
};

withSession is a decorator factory that merges overrides into the mock session context:

function withSession(overrides: Partial<Session>): Decorator {
  return (Story) => (
    <SessionProvider value={mergeSession(mockSession, overrides)}>
      <Story />
    </SessionProvider>
  );
}

The same SessionProvider the real app uses. No special Storybook-only auth system to maintain.

Why this works

All three access dimensions — flags, entitlements, ABAC — read from a single SessionProvider context. Mocking that context in Storybook mocks all three simultaneously. There’s no separate LaunchDarkly mock, no separate plan mock, no separate ABAC mock to keep in sync. One context, one override, full control.