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

@octopi-ai/better-enrollment

v0.5.0

Published

Invite-only signups, role-merging invite links, and organization invites for Better Auth. Private and public invites, org seat limits, full audit trail.

Downloads

482

Readme

Features

  • 🚪 Invite-only mode: close every sign-up route and let people in only through invitations.
  • 🔓 Open mode: invitations become role and organization grants for self-serve sign-ups.
  • ✉️ Private invites: email-bound, single use, verified on accept.
  • 🔗 Public invites: shareable links with use caps, expiry, and revocation.
  • 🏢 Organization onboarding: one link joins an org, or lets the invitee found their own.
  • 💺 Seat limits: per-org caps with pending-invite reservations and subscription hooks.
  • 🛡️ Security first: hashed tokens, atomic race-safe redemption, no email oracles.
  • 🧾 Audit trail: append-only record of who invited whom and who redeemed what.
  • 🧩 One redemption page: a single ?token= page and one redeem call handle every invite kind in both modes.
  • ⚙️ Adapter-agnostic: works with any Better Auth database adapter, no extra infrastructure.

How it works

Better Enrollment runs in one of two modes, auto-detected from your config: invite-only (every sign-up route is closed, invitations are the only way in) or open (normal sign-up, invites grant roles and organization membership). See The two modes.

Private invites are bound to one email. Creating one pre-creates an inert, unverified user, which locks that address on every path (sign-in, sign-up, password reset, OAuth linking) without revealing that the invite exists. The link is emailed by your sendPrivateInvitation and never shown to its creator, so the token only ever exists in the recipient's mailbox. Redeeming it sets their password and marks the email verified: presenting the token is proof of mailbox access.

Public invites are shareable links with a use cap. Nothing is pre-created; the accepter types their own email at redemption, and since holding a shared link proves nothing about a mailbox, the account is created unverified. The plugin then sends Better Auth's standard verification email (when sendOnSignUp or requireEmailVerification is configured), and the user verifies by clicking it like any other signup.

Redemption never creates a session. After redeeming, the user signs in through your normal flow with the credentials they just set:

await authClient.invite.redeem({ token, password, name, email });
await authClient.signIn.email({ email, password });

Recommended. Keep requireEmailVerification: true in your auth.ts unless you want unverified users signing in. With it, a public-invite accepter cannot sign in until they click the verification link; private-invite accepters are already verified by the invite itself.

export const auth = betterAuth({
  emailVerification: {
    sendVerificationEmail: async ({ user, url }) => {
      await sendEmail(user.email, "Verify your email", url);
    },
    sendOnSignUp: true // also fires for public-invite redemptions
  },
  emailAndPassword: { requireEmailVerification: true }
});

Tokens are crypto-random and stored SHA-256 hashed, every state change is a guarded atomic write (parallel redemptions of a one-seat invite produce exactly one winner), and expiry is derived at read time, so there is no cron and nothing to sweep. Details in How it works and Security.

Role changes made outside this plugin do not merge. Better Auth stores multiple roles as one comma-separated string, and only invite redemption merges into it as a union. A bare setRole({ role: "admin" }) silently strips invite-granted roles; always send the full set. See the recipe.


Installation

npm install @octopi-ai/better-enrollment

Requires better-auth >= 1.4.0 and zod >= 4.

Add the plugin to your betterAuth config (this example is a fully closed app) and your auth client, then migrate:

// auth.ts
import { betterAuth } from "better-auth";
import { admin } from "better-auth/plugins";
import { betterEnrollment } from "@octopi-ai/better-enrollment";

export const auth = betterAuth({
  emailAndPassword: { enabled: true, disableSignUp: true },
  plugins: [
    admin(),
    betterEnrollment({
      async sendPrivateInvitation({ email, url }) {
        await sendInvitationEmail(email, url);
      }
    })
  ]
});
// auth-client.ts
import { createAuthClient } from "better-auth/react";
import { betterEnrollmentClient } from "@octopi-ai/better-enrollment/client";

export const authClient = createAuthClient({
  plugins: [betterEnrollmentClient()]
});
npx @better-auth/cli migrate # creates the invite and inviteUse tables

Role field required. Invited roles live in a string role field on your user model, and the default admin gate reads it. The admin plugin provides one, or add it via user.additionalFields; otherwise gate management with adminUserIds or canManageInvites.

Full walkthrough: Quick Start.


Invite kinds and delivery

Two choices define every invite: what it grants (kind) and how it travels (type).

| Kind | Grants | Who may create it | | --------------- | -------------------------------------- | ------------------------------------------------ | | app (default) | Access to the app | App admins | | org-join | Membership in an existing organization | That org's members with invitation: ["create"] | | org-create | Founding and owning a new organization | App admins |

| | Private | Public | | ------------------------ | --------------------------------------- | ---------------------------------------- | | Bound to | One email address | Nobody | | Uses | Always exactly 1 | maxUses, or unlimited when null | | Delivery | Emailed by your sendPrivateInvitation | A link you distribute | | Link visible to creator | Never | Yes, returned once | | Email verified on accept | Yes | No, unless autoVerifyPublicInviteEmail |

No email delivery? Use a public invite with maxUses: 1. There is deliberately no option to reveal a private link, because possession of one is proof of mailbox access.

// Private: emailed, single use
await auth.api.createInvite({
  body: { type: "private", email: "[email protected]", name: "Ada", role: "user" },
  headers
});
// -> { inviteId, expiresAt }

// Public: a capped shareable link, returned once
await auth.api.createInvite({
  body: { type: "public", role: "user", maxUses: 50 },
  headers
});
// -> { inviteId, expiresAt, token, url }

Org invites (org-join, org-create), inviting existing accounts, and every field: Invites.


The invite page

Every invitation link points at the same page, carrying only ?token=. The page calls invite.get, renders what nextAction says, and submits everything to invite.redeem; it never needs to know the invite kind or the mode.

| invite.nextAction | Meaning | What to render | | ------------------- | ----------------------------- | -------------------------------------------- | | SIGN_UP | Invite-only, no session | The fields listed in requiredFields | | SIGN_IN | Open mode, no session | Your sign-in form; keep the token in the URL | | CONFIRM | Open mode, session present | A single confirm button | | null | Expired, revoked, or consumed | A clear terminal message |

const { data: invite } = await authClient.invite.get({ token });

await authClient.invite.redeem({ token, password, name, email });
// -> { action: "ACCEPTED", organization? }

await authClient.signIn.email({ email, password });

Sign-up redemptions always collect password and name; public invites add email, and org-create adds organizationName and organizationSlug. Need more than that, say a department or a referral code? Declare additional fields with a type, requiredness, an optional zod validator, and the steps that collect them (SIGN_UP by default, CONFIRM for signed-in activations), and they flow through requiredFields / optionalFields, the redeem body, and onto the user row:

betterEnrollment({
  additionalFields: {
    department: { type: "string" },
    referral: { type: "string", required: false },
    team: { type: "string", actions: ["SIGN_UP", "CONFIRM"] }
  }
});

invite.get returns a deliberately thin payload: kind, role, derived status, expiry, uses remaining, and a masked email. Never the inviter's identity or internal ids. Full rendering guide: The invite page.


Organizations

Org features switch on when the organization plugin is detected. Pass the same ac and roles objects you gave the org plugin:

plugins: [
  admin(),
  organization({ ac, roles }),
  betterEnrollment({
    organization: { ac, roles, defaultOrganizationRole: "member", defaultSeatLimit: 10 }
  })
],
  • Seat limits resolve resolveSeatLimit(org) → seatLimit column → defaultSeatLimit → unlimited. Seats used = members + pending invite reservations, enforced at creation and again at redemption.
  • Platform controls (app-admin only): disable, enable, and delete organizations; banMembers: true bans every member app-wide for fraud takedowns.
  • Org sovereignty: app admins deliberately cannot create org-join invites; only the org invites into itself.
  • Setting the active organization after an org invite is your app's job, at sign-in or via a switcher.

Permissions table, seat math, and platform controls: Organizations.


API

// Redemption, for the invite page
authClient.invite.get({ token });
authClient.invite.redeem({ token, ... });

// Management (admin- or org-gated)
authClient.invite.create({ ... });
authClient.invite.list({ ... });
authClient.invite.resend({ inviteId }); // rotate the token, invalidate the old link, redeliver
authClient.invite.revoke({ inviteId });
authClient.invite.delete({ inviteId });

// Organization administration
authClient.invite.org.usage({ organizationId });
authClient.invite.org.setSeatLimit({ organizationId, seatLimit });
authClient.invite.org.disable({ organizationId, banMembers? });
authClient.invite.org.enable({ organizationId });
authClient.invite.org.delete({ organizationId, banMembers? });

Server-only, never mounted as HTTP routes: headless invite creation for cron jobs and system integrations, plus batched cleanup for your scheduler.

await auth.api.createSystemInvite({
  body: {
    type: "private",
    email: "[email protected]",
    inviter: { name: "Billing", email: "[email protected]" } // optional
  }
});

const { deleted } = await auth.api.cleanupExpiredInvites();

Trusted server code only. createSystemInvite skips authentication and the admin gate; whoever can call it can mint invites for any role. Keep it in code paths you fully control, never behind a client-reachable route, and never forward unvalidated client input into it. It also thins the audit trail: system invites store a null createdByUserId and a self-declared inviter.

Every method, payloads, and rate limits: API reference.


Options

betterEnrollment({
  mode: "auto", // "auto" | "invite-only" | "open"
  sendPrivateInvitation,
  sendPublicInvitation,
  validRoles: ["user", "admin"],
  defaultRole: "user",
  expiresIn: 60 * 60 * 24 * 7,
  ac, // the admin plugin's access-control file, shared as-is
  roles, // roles with invite:<action> grants; falls back to adminRoles below
  adminRoles: ["admin"],
  passwordless: "auto", // magic-link apps: accept invites without a password
  additionalFields: {/* extra sign-up fields, see docs */},
  buildInviteUrl,
  organization: {/* ... */},
  onInviteCreated,
  onInviteAccepted,
  onInviteRevoked /* ... */
});

Every option with its default: Options.


Security

While a private invite is pending, its email is locked on every path with no oracle: sign-up is disabled, sign-in fails naturally, password reset returns a byte-identical silent success, and OAuth linking is blocked. Accepted invites are permanent audit records; revoking keeps the lock, deleting frees the address.

The full block table, the email-lock semantics, and token storage: Security. Report vulnerabilities privately to [email protected].


More

  • Error codes: every APIError code the plugin returns.
  • Database: the invite and inviteUse tables, every column and index, plus a Prisma example.
  • Recipes: captcha, breached-password checks, custom URLs, role-change patterns.
  • Operations at scale: batched cleanup, bulk org operations, composite indexes, shared rate limits.
  • Roadmap: runtime sign-up backstop, an Agent Skill.
  • Releases: the changelog, with migration notes per version.

Have an idea or found a problem? Open an issue.

License

MIT