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

@evgenkoshevoy/better-auth-application

v0.0.3

Published

Application plugin for better-auth with per-app roles and deny-override access control

Readme

application — application registry plugin for better-auth

An application registry (analogous to Atlassian: Jira / Bitbucket / Confluence) with access control at the user, organization, and OAuth client (OIDC Provider) level. Supports CASL-compatible access rules stored in application metadata.

Two Collections

| Table | Purpose | |---|---| | application | Application catalog (slug, name, enabled, roles, defaultRole, url, ...). Each application has its own set of roles. url — one or more trusted origin URLs for this application (required) | | applicationAccess | Unified access table. Polymorphic subject: subjectType (user / organization / oauthClient) + subjectId, plus effect (allow/deny), role, enabled |

Roles

application.roles — an ordered array from weakest to strongest (order defines seniority when multiple grants apply), e.g. ["viewer", "developer", "admin"]. defaultRole — role for an allow-grant without an explicit role; it is recommended to always set this explicitly. When granting access, the role is validated against the application's roles. The final role is the strongest among all applicable grants.

FK (references + onDelete: cascade) is only on applicationId. There is no FK on subjectId — it is polymorphic. Orphaned rows are cleaned up by an after-hook when a user or organization is deleted.

Permission Model (deny-override)

For Users

checkAccess(user, app, org):

  1. The application must be enabled;
  2. Only rows with enabled = true are considered;
  3. An explicit deny for the user overrides everything → access is denied;
  4. Otherwise access is granted if the user has allow or their organization (where they are a member) has allow;
  5. The final role = the strongest among all applicable grants.

source in the response: user-allow / org-grant / user-deny / app-disabled / no-grant.

For OAuth Clients

Direct grant check by clientId (no org hierarchy):

  1. The application must be enabled;
  2. An active allow-grant must exist for subjectType: "oauthClient", subjectId: clientId;
  3. A deny-grant blocks access.

source in the response: client-allow / client-deny / app-disabled / no-grant.

Setup

import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins";
import { application } from "@evgenkoshevoy/better-auth-application";

const appPlugin = application(); // isAdmin default: session.user.role === "admin"

export const auth = betterAuth({
  trustedOrigins: appPlugin.getTrustedOrigins, // load trusted origins from all registered applications
  plugins: [
    organization(),
    appPlugin,
  ],
});

Trusted Origins

appPlugin.getTrustedOrigins is an async function compatible with better-auth's trustedOrigins option. On the first request it queries the database for all registered applications, collects their url values, and caches them in memory. The cache is automatically updated when applications are created, and invalidated (re-fetched on next call) when applications are updated or deleted.

Custom admin predicate:

application({ isAdmin: (s) => s.user.role === "superadmin" })

Client:

import { createAuthClient } from "better-auth/client";
import { organizationClient } from "better-auth/client/plugins";
import { applicationClient } from "@evgenkoshevoy/better-auth-application/client";

export const authClient = createAuthClient({
  plugins: [
    organizationClient(),
    applicationClient({ appId: "jira" }), // id or slug; can be a function for dynamic resolution
  ],
});

Migrations:

npx @better-auth/cli generate
npx @better-auth/cli migrate

x-app-id Header

The client plugin adds x-app-id to all requests — the server knows which application the user is coming from. The value is an id or slug (the server resolves both).

checkAccess and myAbilities without an explicit applicationId take the application from this header:

const { data } = await authClient.application.checkAccess();

Read it in your own endpoint: request.headers.get("x-app-id"). For server-side route protection:

await auth.api.checkAccess({ headers: request.headers })

Examples

Application Management (admin)

// Create an application with roles and trusted URL(s)
await authClient.application.createApplication({
  slug: "jira",
  name: "Jira",
  roles: ["viewer", "developer", "admin"],
  defaultRole: "viewer",
  url: ["https://jira.example.com"], // required; string or array of strings
});

// Grant access to a user
await authClient.application.setAccess({
  applicationId, subjectType: "user", subjectId: userId, role: "developer",
});

// Grant access to an organization
await authClient.application.setAccess({
  applicationId, subjectType: "organization", subjectId: orgId, role: "developer",
});

// Grant access to an OAuth client (clientId from oidc-provider)
await authClient.application.setAccess({
  applicationId, subjectType: "oauthClient", subjectId: "my-client-id", role: "viewer",
});

// Explicit user deny — overrides their organization's grant
await authClient.application.setAccess({
  applicationId, subjectType: "user", subjectId: userId, effect: "deny",
});

// Revoke a grant
await authClient.application.revokeAccess({ applicationId, subjectType: "user", subjectId: userId });

// All grants for an application
await authClient.application.listAccess({ query: { applicationId } });

Access Check (user)

// Check current user's access (org is taken from the active session)
const { data } = await authClient.application.checkAccess({ query: { applicationId } });
// data => { hasAccess: true, role: "developer", source: "user-allow" }

// Applications available to the current user
const { data } = await authClient.application.myApplications();

Access Check (OAuth client)

An OAuth client arrives with its own access token at an external API. That API checks whether the client has access, knowing its own applicationId:

// External API (server-side)
const response = await fetch("/api/auth/application/client-abilities", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    accessToken: bearerTokenFromRequest, // token received from the OAuth client
    applicationId: "jira",               // the application's own ID
  }),
});
const { hasAccess, role, rules } = await response.json();

CASL Rules (Variant B)

Access rules are stored in application.permissions — an object of the form { [role]: AbilityRule[] } in CASL format. Rules can be changed without a deploy via updateApplication.

Permissions Structure

await authClient.application.createApplication({
  slug: "jira",
  name: "Jira",
  roles: ["viewer", "licenseManager", "admin"],
  defaultRole: "viewer",
  url: "https://jira.example.com",
  permissions: {
    viewer: [
      { action: "list",  subject: "License" },
      { action: "read",  subject: "License" },
    ],
    licenseManager: [
      { action: ["list", "read", "renew"], subject: "License" },
      { action: "create", subject: "License", conditions: { type: "simple" } },
    ],
    admin: [
      { action: "manage", subject: "all" },
    ],
  },
});

Supported rule fields (AbilityRule):

| Field | Type | Description | |---|---|---| | action | string \| string[] | Action: "list", "read", "create", "manage", ... | | subject | string \| string[] | Resource: "License", "all", ... | | conditions | Record<string, unknown> | MongoDB-style conditions for ABAC | | fields | string[] | Field-level restriction | | inverted | boolean | true — a deny rule (cannot) |

Get Ready-to-Use CASL Rules

For a user:

const { data } = await authClient.application.myAbilities({
  query: { applicationId: "jira" },
});
// data => { hasAccess: true, role: "licenseManager", rules: [...] }

import { createMongoAbility, subject } from "@casl/ability";
const ability = createMongoAbility(data.rules);

ability.can("list", "License")                                   // → true
ability.can("create", subject("License", { type: "simple" }))   // → true
ability.can("create", subject("License", { type: "advanced" })) // → false

For an OAuth client (returns the same rules along with the access result):

// POST /application/client-abilities
// { accessToken, applicationId } → { hasAccess, role, rules }

If permissions is not set, rules will be [] — the application itself decides how to handle the absence of rules.

Notes

  • Deprecated roles: if a role is removed from application.roles, grants with that role are lazily nulled in the DB (role → null) on the next access check and resolved to defaultRole. The order of elements in roles only affects the selection of the strongest role when multiple grants apply — not fallback behavior.
  • OAuth clients: subjectId for oauthClient is the clientId from oauthApplication (OIDC Provider). When an OAuth client is deleted, its grants must be cleaned up manually via revokeAccess — there is no automatic hook.
  • Composite uniqueness (applicationId + subjectType + subjectId) is enforced via upsert in code. For a DB-level guarantee, add a unique index via migration.
  • myApplications resolves each application individually (N queries). For a large catalog, replace with a batch query on applicationAccess rows and in-memory resolution.
  • The organization deletion hook path (/organization/delete...) should be verified against your version of the organization plugin — adjust the matcher if needed.