Invite-only registration plugin for Better Auth. Gates signups with admin-managed invitation codes, supports email + OAuth flows, full CRUD admin endpoints.
src/
index.ts Server plugin entry — schema, hooks, endpoint wiring, init()
client.ts Client plugin — typed actions (createInvitation, validate, etc.), cookie helpers
types.ts All TypeScript interfaces and types (no runtime code)
constants.ts Defaults, error codes, magic numbers
admin-endpoints.ts Admin endpoints: create, create-batch, list, stats
admin-mutations.ts Admin mutations: revoke, resend, delete
admin-queries.ts Admin query endpoints
admin-helpers.ts Shared admin helpers: resolveIsAdmin, makeCode, getBaseUrl
public-endpoints.ts Public endpoints: validate, config
hooks.ts Before/after hooks — invite gate on signup, code consumption
invite-store.ts MemoryInviteStore (default, not multi-process safe)
adapter-helpers.ts DB adapter utilities
crypto.ts Hashing (SHA-256), code generation
utils.ts Pure functions — status computation, cookie parsing, URL building
*.test.ts Co-located test files
Two entry points via tsup: src/index.ts (server plugin) and src/client.ts (client plugin). Dual ESM/CJS output to dist/.
- Formatter/Linter: Biome v2 via Ultracite (
npx ultracite check/npx ultracite fix) - Config:
biome.jsoncextendsultracite/core, relaxes naming conventions andnoExplicitAny - TypeScript: strict mode,
verbatimModuleSyntax,noUncheckedIndexedAccess, ES2022 target, bundler module resolution - File size: under 250 LOC (hard limit 280)
- Zod: uses Zod 4.x (via better-auth 1.5) — use
z.string().email(), notz.email() - Error codes: use
defineErrorCodes()from@better-auth/core/utils/error-codes— produces{ code, message }objects, not plain strings - APIError: use
APIError.from("STATUS", ERROR_CODES.X)— notnew APIError("STATUS", { message }) - lint-staged: runs
npx ultracite fixon commit for*.{ts,tsx,js,jsx,json,jsonc,css}
- Framework: Vitest 4.x with happy-dom environment
- Location: co-located
*.test.tsfiles insrc/ - Coverage: v8 provider, thresholds at 90% lines/branches/statements, 80% functions
- Patterns:
- Pure functions: direct unit tests
- Client plugin: mock
$fetch, test actions and cookie manipulation - Server plugin: mock
ctx.context.adapterandctx.context.session - Hooks: test handler functions directly
- Security: adversarial inputs, timing, concurrency
- Globals: enabled (
describe,it,expectavailable without import, though tests do import them)
npm run build # tsup — dual ESM/CJS + .d.ts
npm run dev # tsup --watch
npm run type-check # tsc --noEmit
npm test # vitest run
npm run test:watch # vitest
npm run test:coverage # vitest run --coverage
npm run lint # npx ultracite check
npm run lint:fix # npx ultracite fixPeer: better-auth >= 1.5.0
Dev only (nothing at runtime):
@biomejs/biome,ultracite— lintingvitest,@vitest/coverage-v8,@vitest/ui,happy-dom— testingbetter-sqlite3,mongodb,mongodb-memory-server— test DB backendstsup,typescript— buildlint-staged— pre-commit
- Security first: invite codes are SHA-256 hashed before storage. Never store or log plaintext codes. Never return PII in public endpoints.
- Adapter-only DB access: use
ctx.context.adaptermethods (findOne,findMany,create,update,count,delete). Never import Prisma/Drizzle directly. - Input validation: all string inputs bounded with
z.string().min(1).max(256). Cursor dates validated withz.string().datetime(). - Cookie security:
Secureon HTTPS,SameSite=Lax, 5-min TTL, alwaysdecodeURIComponentafter extraction. - OAuth collision prevention: pending invite keys use
__code:{specificCode}format, not email-based. - Push filtering to DB: use
whereclauses andadapter.count()for aggregations, not post-fetch JS filtering. - Coverage enforcement: 90% lines/branches/statements, 80% functions. Tests must pass before merge.
- No Claude/Anthropic mentions in code, GitHub, or docs outside this file.