npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

accessly

v0.1.4

Published

Explainable access control for React and Next.js — permission checks, RBAC, feature flags, backend adapters, and explainable decisions

Readme

Accessly

Explainable access control for React and Next.js.



Accessly is a small permission layer for React applications. It helps you render UI from a normalized access model, check permissions and feature flags, filter navigation, adapt backend responses, and inspect exactly why a decision was allowed or denied.

It is designed for product teams that need frontend access logic to be consistent, debuggable, and easy to integrate with real backends.

Accessly is frontend access control. It controls what React renders for the current authenticated user; it does not replace backend authorization.

Highlights

  • Explainable decisions: every check returns an AccessDecision with allowed, reason, requested, matched, missing, and checkedFrom.
  • React-first API: use components like Can, Cannot, and ProtectedRoute, or hooks like usePermission and useAccessDecision.
  • RBAC support: expand user roles into permissions with rolePermissions.
  • Wildcard permissions: match permissions such as users.*, reports.*, or global *.
  • Feature flags: check feature availability with the same provider and decision model.
  • Backend adapters: normalize any backend shape into Accessly's AccessModel.
  • Built-in adapters: flat permissions, grouped actions, pages-only access, nested modules, and feature flags.
  • Navigation filtering: remove inaccessible links and nested menu items from your app navigation.
  • Debug utilities: format decisions and inspect the active access model.
  • Outside React checks: reuse an access model with createAccessChecker.
  • Type guards: narrow unknown JSON with lightweight runtime shape checks.
  • TypeScript-first: ships generated .d.ts and .d.cts declarations.
  • Zero runtime dependencies: Accessly has no regular runtime dependencies. React is a peer dependency.
  • Tree-shaking friendly: ships ESM, CJS, package exports, and "sideEffects": false.

Installation

npm install accessly

Other package managers:

pnpm add accessly
yarn add accessly
bun add accessly

Accessly expects React to be installed in your app:

{
  "peerDependencies": {
    "react": ">=18.0.0"
  }
}

Quick Start

Wrap your app with PermissionProvider, then render gated UI with Can.

import { PermissionProvider, Can } from "accessly";

export function App() {
  return (
    <PermissionProvider
      access={{
        user: { id: "user_1", roles: ["admin"] },
        permissions: ["users.create", "users.view"],
        flags: ["features.new-dashboard"],
      }}
    >
      <Can permission="users.create" fallback={<span>Read only</span>}>
        <button>Create user</button>
      </Can>
    </PermissionProvider>
  );
}

The Access Model

Accessly reads one normalized shape: AccessModel.

Your backend can look however it wants. Accessly only needs the data normalized into this model.

AccessModel is for the current authenticated user/session only. Do not pass every user in your system to PermissionProvider.

type AccessModel = {
  user?: {
    id?: string;
    roles?: string[];
    attributes?: Record<string, unknown>;
  };
  permissions?: string[];
  flags?: string[];
  navigation?: NavigationItem[];
  isLoading?: boolean;
};

type NavigationItem = {
  label: string;
  href?: string;
  permission?: string;
  children?: NavigationItem[];
};

Why normalize?

Most teams do not have identical authorization APIs:

  • Some APIs return permissions: ["users.create"].
  • Some return grouped actions like { users: ["create", "delete"] }.
  • Some return roles only.
  • Some return flags from a separate service.
  • Some return page access instead of action permissions.

Accessly gives your UI one consistent model, while adapters keep backend-specific mapping at the edge.

PermissionProvider

PermissionProvider makes an access model available to Accessly components and hooks.

The provider stores access data in React Context. usePermission, useAccessDecision, and useAccessModel read from that provider context. checkPermission is pure and requires access data manually.

import { PermissionProvider } from "accessly";

export function Root() {
  return (
    <PermissionProvider
      access={{
        user: { id: "user_1", roles: ["manager"] },
        permissions: ["reports.view", "reports.export"],
        flags: ["features.audit-log"],
      }}
    >
      <App />
    </PermissionProvider>
  );
}

Provider props

type PermissionProviderProps = {
  children: React.ReactNode;
  access?: AccessModel | null;
  source?: unknown;
  adapter?: AccessAdapter<unknown>;
  rolePermissions?: RolePermissions;
  registry?: readonly string[];
  unknownPermission?: "ignore" | "warn" | "throw";
  loading?: boolean;
};

Use access when you already have an AccessModel.

Use source + adapter when your backend returns a custom shape.

Components

Can

Render children only when a permission check is allowed.

import { Can } from "accessly";

<Can permission="documents.create" fallback={<span>Read only</span>}>
  <button>New document</button>
</Can>;

Cannot

Render children when a permission check is denied.

import { Cannot } from "accessly";

<Cannot permission="billing.manage">
  <p>Billing settings are restricted for your account.</p>
</Cannot>;

ProtectedRoute

Gate a route, page section, or larger UI boundary.

import { ProtectedRoute } from "accessly";

<ProtectedRoute
  permission="admin.view"
  fallback={<p>You do not have access to this page.</p>}
  loading={<p>Checking access...</p>}
>
  <AdminPage />
</ProtectedRoute>;

ProtectedRoute renders children, loading, or fallback UI. It does not redirect automatically; put router-specific redirects in your fallback when your app needs them.

Render-prop access

Can, Cannot, and ProtectedRoute can receive a function child when you want full access to the decision.

<Can permission="reports.export">
  {(decision) =>
    decision.allowed ? (
      <button>Export</button>
    ) : (
      <span>Missing: {decision.missing?.join(", ")}</span>
    )
  }
</Can>

Hooks

usePermission

Returns a boolean.

import { usePermission } from "accessly";

export function ExportButton() {
  const canExport = usePermission("reports.export");

  if (!canExport) return null;

  return <button>Export report</button>;
}

useAccessDecision

Returns the full decision object.

import { useAccessDecision } from "accessly";

export function DeleteUserButton() {
  const decision = useAccessDecision("users.delete");

  if (!decision.allowed) {
    return <span>Denied: {decision.reason}</span>;
  }

  return <button>Delete user</button>;
}

useAccessModel

Reads the current normalized model.

import { useAccessModel } from "accessly";

export function AccountBadge() {
  const model = useAccessModel();

  return <span>{model?.user?.id ?? "anonymous"}</span>;
}

Permission Checks

Accessly supports four check inputs.

Single permission

<Can permission="users.create">
  <button>Create user</button>
</Can>

Equivalent object form:

<Can permission={{ permission: "users.create" }}>
  <button>Create user</button>
</Can>

Any permission

Allowed when at least one requested permission matches.

<Can permission={{ any: ["users.create", "users.invite"] }}>
  <button>Add teammate</button>
</Can>

All permissions

Allowed only when every requested permission matches.

<Can permission={{ all: ["reports.view", "reports.export"] }}>
  <button>Export report</button>
</Can>

Feature flag

Allowed when the requested flag exists in model.flags.

<Can permission={{ flag: "features.new-dashboard" }}>
  <NewDashboard />
</Can>

Explainable Decisions

The core difference between Accessly and a plain boolean helper is that Accessly can tell you why a check passed or failed.

import { useAccessDecision } from "accessly";

export function DecisionPreview() {
  const decision = useAccessDecision("reports.export");

  return <pre>{JSON.stringify(decision, null, 2)}</pre>;
}

Example allowed decision:

{
  "allowed": true,
  "reason": "allowed",
  "requested": ["reports.export"],
  "matched": ["reports.*"],
  "checkedFrom": "wildcard"
}

Example denied decision:

{
  "allowed": false,
  "reason": "missing_permission",
  "requested": ["billing.manage"],
  "missing": ["billing.manage"],
  "checkedFrom": "none"
}

Decision type:

type AccessDecision = {
  allowed: boolean;
  reason:
    | "allowed"
    | "missing_permission"
    | "missing_flag"
    | "unknown_permission"
    | "not_ready"
    | "invalid_request";
  requested?: string[];
  missing?: string[];
  matched?: string[];
  checkedFrom?: "direct" | "role" | "wildcard" | "flag" | "none";
};

Debugging Access Decisions

When a button, link, or route is hidden, ask Accessly for the decision before guessing. The decision tells you what was requested, what matched, what is missing, where the match came from, and whether access is still loading.

React debugging with useAccessDecision

import { formatDecision, useAccessDecision } from "accessly";

export function RefundButtonDebug() {
  const decision = useAccessDecision("payments.refund");

  if (!decision.allowed) {
    return <pre>{formatDecision(decision)}</pre>;
  }

  return <button>Refund payment</button>;
}

Pure debugging with checkPermission

import { checkPermission, formatDecision, inspectAccess } from "accessly";

const access = {
  permissions: ["reports.*"],
  flags: ["features.beta"],
};

const allowed = checkPermission(access, { permission: "reports.export" });
const denied = checkPermission(access, { permission: "billing.manage" });

console.log(formatDecision(allowed));
console.log(formatDecision(denied));
console.log(inspectAccess(access));

Allowed decision:

{
  "allowed": true,
  "reason": "allowed",
  "requested": ["reports.export"],
  "matched": ["reports.*"],
  "checkedFrom": "wildcard"
}

Denied decision:

{
  "allowed": false,
  "reason": "missing_permission",
  "requested": ["billing.manage"],
  "missing": ["billing.manage"],
  "checkedFrom": "none"
}

Loading/not-ready decision:

{
  "allowed": false,
  "reason": "not_ready"
}

For local development, you can copy this small debug component into your app. Accessly does not export it because production debug UI should stay under your control.

import { formatDecision, useAccessDecision } from "accessly";
import type { PermissionCheckInput } from "accessly";

export function AccessDecisionDebug({
  permission,
  label = "Access decision",
  className,
}: {
  permission: string | PermissionCheckInput;
  label?: string;
  className?: string;
}) {
  const decision = useAccessDecision(permission);

  return (
    <section aria-label={label} className={className}>
      <strong>{label}</strong>
      <pre>{formatDecision(decision)}</pre>
    </section>
  );
}

RBAC

Accessly can expand user roles into permissions with rolePermissions.

import { PermissionProvider, Can } from "accessly";

const rolePermissions = {
  admin: ["users.*", "billing.manage", "reports.export"],
  support: ["users.view", "tickets.*"],
};

export function App() {
  return (
    <PermissionProvider
      access={{
        user: { id: "user_1", roles: ["support"] },
        permissions: ["dashboard.view"],
      }}
      rolePermissions={rolePermissions}
    >
      <Can permission="tickets.close">
        <button>Close ticket</button>
      </Can>
    </PermissionProvider>
  );
}

If a permission is granted through role expansion, checkedFrom is "role".

Wildcard Permissions

Accessly supports segment-based wildcard matching.

matchPermission("users.*", "users.create"); // true
matchPermission("users.*", "users.delete"); // true
matchPermission("users.*", "users.profile.edit"); // false
matchPermission("*", "anything.here"); // true

Rules:

  • * matches everything.
  • users.* matches one segment after users.
  • users.profile.* matches users.profile.edit.
  • Wildcards are segment-based; Accessly does not support globstar patterns like users.**.

Backend Adapters

Use createAdapter when your backend shape is custom.

import { PermissionProvider, createAdapter } from "accessly";

type BackendUser = {
  id: string;
  roles: string[];
  perms: string[];
  featureFlags: string[];
};

const backendAdapter = createAdapter((source: BackendUser) => ({
  user: {
    id: source.id,
    roles: source.roles,
  },
  permissions: source.perms,
  flags: source.featureFlags,
}));

export function App({ user }: { user: BackendUser }) {
  return (
    <PermissionProvider source={user} adapter={backendAdapter}>
      <Product />
    </PermissionProvider>
  );
}

Adapter type:

type AccessAdapter<TSource = unknown> = {
  normalize: (source: TSource) => AccessModel;
};

Built-in Adapters

directPermissionsAdapter

For APIs that already return permissions, roles, and flags.

import { directPermissionsAdapter } from "accessly";

const model = directPermissionsAdapter({
  roles: ["admin"],
  permissions: ["users.create"],
  flags: ["features.audit-log"],
});

createActionsAdapter

For APIs that group allowed actions by resource.

import { createActionsAdapter } from "accessly";

const model = createActionsAdapter({
  users: ["create", "delete"],
  reports: ["view"],
});

// permissions: ["users.create", "users.delete", "reports.view"]

pagesOnlyAdapter

For APIs that only return page or route access.

import { pagesOnlyAdapter } from "accessly";

const model = pagesOnlyAdapter({
  pages: ["dashboard", "settings"],
});

// permissions: ["pages.dashboard", "pages.settings"]

nestedModulesAdapter

For nested module/action maps.

import { nestedModulesAdapter } from "accessly";

const model = nestedModulesAdapter({
  users: {
    create: true,
    delete: false,
  },
  reports: {
    export: true,
  },
});

// permissions: ["users.create", "reports.export"]

featureFlagsAdapter

For feature flag APIs that return boolean maps.

import { featureFlagsAdapter } from "accessly";

const model = featureFlagsAdapter({
  features: {
    "new-dashboard": true,
    "legacy-admin": false,
  },
});

// flags: ["features.new-dashboard"]

Navigation Filtering

Accessly can filter navigation arrays using the same permission engine.

import {
  filterNavigation,
  type AccessModel,
  type NavigationItem,
} from "accessly";

const items: NavigationItem[] = [
  { label: "Dashboard", href: "/dashboard", permission: "dashboard.view" },
  { label: "Users", href: "/users", permission: "users.view" },
  { label: "Billing", href: "/billing", permission: "billing.view" },
];

export function visibleNavigation(access: AccessModel) {
  return filterNavigation(items, access);
}

Nested navigation is supported:

const items: NavigationItem[] = [
  {
    label: "Admin",
    permission: "admin.view",
    children: [
      { label: "Users", href: "/admin/users", permission: "users.view" },
      { label: "Billing", href: "/admin/billing", permission: "billing.view" },
    ],
  },
];

If all children are filtered out, the parent item is removed too.

Hook form:

import { useFilteredNavigation } from "accessly";

const visibleItems = useFilteredNavigation(items, accessModel);

Unknown Permission Handling

Use registry with unknownPermission when you want to catch checks for permissions your app does not recognize.

<PermissionProvider
  access={{ permissions: ["users.view"] }}
  registry={["users.view", "users.create", "billing.manage"]}
  unknownPermission="throw"
>
  <App />
</PermissionProvider>

Strategies:

  • "ignore": denied unknown permissions behave like normal missing permissions.
  • "warn": keeps the normal missing-permission decision shape, which is useful when your app wants to layer warning behavior around denied unknown checks.
  • "throw": returns a decision with reason: "unknown_permission".

Debug Utilities

formatDecision

Turn a decision into a readable string.

import { formatDecision, checkPermission } from "accessly";

const decision = checkPermission(
  { permissions: ["reports.*"] },
  { permission: "reports.export" },
);

console.log(formatDecision(decision));

Output:

Allowed: true
Reason: allowed
Requested: reports.export
Matched: reports.*
Checked from: wildcard

inspectAccess

Print the active access model.

import { inspectAccess } from "accessly";

console.log(
  inspectAccess({
    user: { id: "user_1", roles: ["admin"] },
    permissions: ["users.*"],
    flags: ["features.audit-log"],
  }),
);

Core Engine API

You can use Accessly without React components when you need a direct decision.

import { checkPermission } from "accessly";

const decision = checkPermission(
  {
    user: { roles: ["admin"] },
    permissions: ["dashboard.view"],
  },
  { permission: "dashboard.view" },
);

if (decision.allowed) {
  // Render, redirect, or continue.
}

checkPermission is intentionally stateless. It requires the access model manually because it does not read React Context or any global store.

createAccessChecker

Use createAccessChecker when you want to reuse the same model and options outside React without repeatedly passing them to checkPermission.

import { createAccessChecker } from "accessly";

const checker = createAccessChecker(
  {
    user: { roles: ["admin"] },
    permissions: ["dashboard.view"],
  },
  {
    rolePermissions: {
      admin: ["users.*"],
    },
  },
);

checker.can("users.create");
checker.decision("users.create");
checker.can({ any: ["users.create", "users.invite"] });
checker.decision({ flag: "features.beta" });

If your app has its own auth/session store, create the checker at that store boundary and keep backend authorization separate.

TypeScript DX

Accessly exports its public types from the root package for autocomplete.

import {
  Can,
  PermissionProvider,
  checkPermission,
  createAccessChecker,
  isAccessModel,
} from "accessly";
import type {
  AccessDecision,
  AccessModel,
  NavigationItem,
  PermissionCheckInput,
} from "accessly";

Valid check inputs are typed:

const access: AccessModel = { permissions: ["users.create"] };

checkPermission(access, { permission: "users.create" });
checkPermission(access, { any: ["users.create", "users.invite"] });
checkPermission(access, { all: ["reports.view", "reports.export"] });
checkPermission(access, { flag: "features.beta" });

Invalid check inputs are caught by TypeScript:

// TypeScript error: use `permission`, not `permissions`.
checkPermission(access, { permissions: "users.create" });

// TypeScript error: flag must be a string.
checkPermission(access, { flag: 123 });

// TypeScript error: any must be a string array.
checkPermission(access, { any: "users.create" });

Lightweight type guards

Accessly includes small runtime guards for unknown JSON. They are practical shape checks, not a full validation library.

import { PermissionProvider, isAccessModel } from "accessly";

const data: unknown = await response.json();

if (!isAccessModel(data)) {
  throw new Error("Invalid access model");
}

<PermissionProvider access={data}>
  <App />
</PermissionProvider>;

Package Design

Accessly is built to stay small and predictable.

  • No regular dependencies: package.json has no dependencies.
  • React as a peer dependency: your application controls the React version.
  • ESM and CJS builds: module, main, and exports are provided.
  • Type declarations: ships dist/index.d.ts and dist/index.d.cts.
  • Tree-shaking friendly: "sideEffects": false and ESM output help modern bundlers remove unused exports.
  • Framework friendly: works in React and Next.js client components. The package itself does not require Next.js.

Useful package links:

Complete Public API

// Types
export type {
  AccessModel,
  NavigationItem,
  AccessDecision,
  RolePermissions,
  PermissionCheckInput,
  AccessAdapter,
} from "accessly";

// Engine
export {
  checkPermission,
  createAccessChecker,
  matchPermission,
} from "accessly";

// Type guards
export {
  isAccessAdapter,
  isAccessDecision,
  isAccessModel,
  isNavigationItem,
  isPermissionCheckInput,
} from "accessly";

// Adapters
export {
  createAdapter,
  directPermissionsAdapter,
  createActionsAdapter,
  pagesOnlyAdapter,
  nestedModulesAdapter,
  featureFlagsAdapter,
} from "accessly";

// Provider
export { PermissionProvider } from "accessly";
export type { PermissionProviderProps } from "accessly";

// Hooks
export { usePermission, useAccessDecision, useAccessModel } from "accessly";

// Components
export { Can, Cannot, ProtectedRoute } from "accessly";

// Navigation
export { filterNavigation, useFilteredNavigation } from "accessly";

// Debug
export { formatDecision, inspectAccess } from "accessly";

Security Note

Accessly controls frontend rendering. It helps you hide, show, explain, and organize UI based on access data.

It does not replace server-side authorization. Sensitive actions, data fetching, mutations, billing operations, admin actions, and private API routes must still be authorized on the server.

Known V1 Limitations

  • Wildcard matching is segment-based and does not support deep globstar patterns like users.**.
  • Feature flag checks are exact-match only.
  • Navigation items support one permission string per item.
  • ProtectedRoute does not redirect automatically.
  • user.attributes is available on the model, but Accessly does not currently evaluate attribute expressions for you.
  • Adapter output is trusted. Validate backend data before returning an AccessModel in production.

License

MIT