@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
Maintainers
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 oneredeemcall 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: truein yourauth.tsunless 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-enrollmentRequires 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 tablesRole field required. Invited roles live in a string
rolefield on your user model, and the default admin gate reads it. The admin plugin provides one, or add it viauser.additionalFields; otherwise gate management withadminUserIdsorcanManageInvites.
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)→seatLimitcolumn →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: truebans every member app-wide for fraud takedowns. - Org sovereignty: app admins deliberately cannot create
org-joininvites; 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.
createSystemInviteskips 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 nullcreatedByUserIdand 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
APIErrorcode the plugin returns. - Database: the
inviteandinviteUsetables, 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
