diff --git a/apps/docs/content/docs/offer.mdx b/apps/docs/content/docs/offer.mdx index 6abe8c3..b3f0980 100644 --- a/apps/docs/content/docs/offer.mdx +++ b/apps/docs/content/docs/offer.mdx @@ -11,7 +11,7 @@ To stay up-to-date with the current progress of Better-Auth-Kit, you can track o - [Reverify](/docs/plugins/reverify) - A plugin to reverify a user's identity. - [Legal Consent](/docs/plugins/legal-consent) - A plugin to collect legal consent from your users. (Coming Soon) - [Blockade](/docs/plugins/blockade) - A plugin to blacklist or whitelist users from accessing your application. (Coming Soon) -- [Shutdown](/docs/plugins/shutdown) - A plugin to stop signins or signups at any moment, such as for maintenance. (Coming Soon) +- [Shutdown](/docs/plugins/shutdown) - A plugin to stop signins or signups at any moment, such as for maintenance. ## Libraries diff --git a/apps/docs/content/docs/plugins/shutdown.mdx b/apps/docs/content/docs/plugins/shutdown.mdx index 2e03a7f..2f22a6a 100644 --- a/apps/docs/content/docs/plugins/shutdown.mdx +++ b/apps/docs/content/docs/plugins/shutdown.mdx @@ -6,7 +6,213 @@ description: Stop signins or signups at any moment. - -#### This plugin is not yet available. -View our [roadmap](/roadmap) to see what is coming soon. - + + + ### 1. Install the plugin + + ```package-install + @better-auth-kit/shutdown + ``` + + + + + ### 2. Initialize the plugin + + + + + ```ts title="auth.ts" + import { shutdown } from "@better-auth-kit/shutdown"; + + export const auth = betterAuth({ + plugins: [shutdown({ + /* + * Roles allowed to add shutdown rules + * @default: ["admin"] + */ + allowedRoles: ["admin"], + })] + }); + ``` + + + + When there are multiple instances of the application, the plugin needs to be initialized with a cache that can be revalidated when rules changes. Below there is an example using **Redis**. + + ```ts title="auth.ts" + import { shutdown } from "@better-auth-kit/shutdown"; + import Redis from "ioredis"; + + const redis = new Redis(); + + const cache = { + requireRevalidation: (cb) => { + // subscribe only to the shutdown-rules channel + redis.subscribe("shutdown-rules"); + // trigger the callback when a message is received + redis.on("message", (channel, message) => cb()); + } + onRulesChanged: () => { + redis.publish("shutdown-rules", "sync") + } + }; + + export const auth = betterAuth({ + plugins: [shutdown({ + /* + * Roles allowed to add shutdown rules + * @default: ["admin"] + */ + allowedRoles: ["admin"], + })] + }); + ``` + + + + + + ### 3. Rules Management + + These methods can be called only by the users with `allowedRoles`. + + + + + ```ts + const data = await auth.api.listShutdownRules({ + headers, + }); + + /* + [ + { + id: "XGH61HHHL0s3d3cug7IH2wrIqic1fJ7r", + roles: ["user"], + signUp: true, + signIn: true, + from: new Date(), + to: new Date(), + }, + ] + */ + console.log(data) + ``` + + + + ```ts + const data = await auth.api.createShutdownRule({ + body: { + /* + * Disallow sign-up for the given roles + * @default: true + */ + signUp: true, + /* + * Disallow sign-in for the given roles + * @default: true + */ + signIn: true, + /* + * The roles to apply the shutdown rule to + * @default: [] + */ + roles: ["user"], + /* + * The date to start the shutdown rule + * @default: null + */ + from: new Date(), + /* + * The date to end the shutdown rule + * @default: null + */ + to: new Date() + }, + headers, + }); + + const isAdded = data?.success; + const ruleId = data?.ruleId; + ``` + + + + ```ts + const data = await auth.api.removeShutdownRule({ + body: { + /* + * The rule id to remove + */ + id: "$ruleId", + }, + headers, + }); + + const isRemoved = data?.success; + ``` + + + + + + + + ### 4. Check if sign-in / sign-up is allowed + + You can also check if sign-up is allowed. E.g. hide a signup page or show an alert. + + #### Sign In + ```ts + const { allowed } = await auth.api.isSignInAllowed(); + ``` + + #### Sign Up + + ```ts + const { allowed } = await auth.api.isSignUpAllowed(); + ``` + + + + + +
+ +## Schema + +Table: `shutdown-rules` + + diff --git a/apps/docs/src/components/sidebar-content.tsx b/apps/docs/src/components/sidebar-content.tsx index 1628b1a..b38d7d5 100644 --- a/apps/docs/src/components/sidebar-content.tsx +++ b/apps/docs/src/components/sidebar-content.tsx @@ -72,7 +72,6 @@ export const contents: Content[] = [ href: "/docs/plugins/shutdown", title: "Shutdown", icon: () => , - isNotReady: true, }, { title: "Libraries", diff --git a/bun.lock b/bun.lock index 10f7e96..e4a80a8 100644 --- a/bun.lock +++ b/bun.lock @@ -90,11 +90,12 @@ }, "packages/adapters/convex": { "name": "@better-auth-kit/convex", - "version": "1.1.0", + "version": "1.2.2", "dependencies": { "prettier": "^3.4.2", }, "devDependencies": { + "@better-auth-kit/internal-build": "workspace:*", "@biomejs/biome": "1.9.4", "@changesets/cli": "^2.27.11", "@types/node": "^22.10.5", @@ -154,7 +155,7 @@ }, "packages/libraries/seed": { "name": "@better-auth-kit/seed", - "version": "1.0.6", + "version": "1.0.10", "dependencies": { "better-auth": "^1.2.4", "chalk": "^5.4.1", @@ -230,6 +231,19 @@ "better-auth": "^1.1.21", }, }, + "packages/plugins/shutdown": { + "name": "@better-auth-kit/shutdown", + "version": "0.0.1", + "dependencies": { + "zod": "^3.24.2", + }, + "devDependencies": { + "@better-auth-kit/internal-build": "workspace:*", + }, + "peerDependencies": { + "better-auth": "^1.2.1", + }, + }, "packages/plugins/waitlist": { "name": "@better-auth-kit/waitlist", "version": "0.0.6", @@ -331,6 +345,8 @@ "@better-auth-kit/seed": ["@better-auth-kit/seed@workspace:packages/libraries/seed"], + "@better-auth-kit/shutdown": ["@better-auth-kit/shutdown@workspace:packages/plugins/shutdown"], + "@better-auth-kit/tests": ["@better-auth-kit/tests@workspace:packages/libraries/tests"], "@better-auth-kit/waitlist": ["@better-auth-kit/waitlist@workspace:packages/plugins/waitlist"], diff --git a/package.json b/package.json index 03604cc..7a1eb66 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "build": "turbo run build", - "dev": "turbo run dev --concurrency 11", + "dev": "turbo run dev --concurrency 12", "format": "biome format --write", "lint": "biome lint", "check-types": "turbo run check-types", diff --git a/packages/libraries/tests/src/index.ts b/packages/libraries/tests/src/index.ts index acc21b6..64853c2 100644 --- a/packages/libraries/tests/src/index.ts +++ b/packages/libraries/tests/src/index.ts @@ -14,7 +14,7 @@ import { MongoClient } from "mongodb"; import { betterAuth } from "better-auth"; import { bearer } from "better-auth/plugins/bearer"; import { mongodbAdapter } from "better-auth/adapters/mongodb"; -import Database from "better-sqlite3"; +import BetterDatabase, { type Database } from "better-sqlite3"; import { createAuthClient, type SuccessContext } from "better-auth/react"; import { getBaseURL } from "./utils/url"; import { getMigrations, getAdapter } from "better-auth/db"; @@ -30,6 +30,7 @@ export async function getTestInstance< port?: number; disableTestUser?: boolean; testUser?: Partial; + adminUser?: Partial; testWith?: "sqlite" | "postgres" | "mongodb" | "mysql"; }, ) { @@ -85,7 +86,7 @@ export async function getTestInstance< ? mongodbAdapter(await mongodbClient()) : testWith === "mysql" ? { db: mysql, type: "mysql" } - : new Database(dbName), + : new BetterDatabase(dbName), emailAndPassword: { enabled: true, }, @@ -95,6 +96,16 @@ export async function getTestInstance< advanced: { cookies: {}, }, + user: { + additionalFields: { + role: { + type: "string", + input: false, + required: false, + defaultValue: "user", + }, + }, + }, } satisfies BetterAuthOptions; const auth = betterAuth({ @@ -114,14 +125,38 @@ export async function getTestInstance< name: "test user", ...config?.testUser, }; - async function createTestUser() { + + const adminUser = { + email: "admin@admin.com", + password: "admin123456", + name: "admin user", + ...config?.adminUser, + }; + + async function createTestUser(user: Partial, role?: string) { if (config?.disableTestUser) { return; } //@ts-expect-error const res = await auth.api.signUpEmail({ - body: testUser, + body: user, }); + + if (res.user && role) { + await (await auth.$context).adapter.update({ + model: "user", + update: { + role, + }, + where: [ + { + field: "id", + operator: "eq", + value: res.user.id, + }, + ], + }); + } } if (testWith !== "mongodb") { @@ -132,7 +167,8 @@ export async function getTestInstance< await runMigrations(); } - await createTestUser(); + await createTestUser(testUser); + await createTestUser(adminUser, "admin"); afterAll(async () => { if (testWith === "mongodb") { @@ -159,13 +195,17 @@ export async function getTestInstance< return; } + // Fix resource-locking issue on Windows + (opts?.database as Database).close(); + await fs.unlink(dbName); }); - async function signInWithTestUser() { + async function signInWithTestUser(role: "user" | "admin" = "user") { if (config?.disableTestUser) { throw new Error("Test user is disabled"); } + const user = role === "admin" ? adminUser : testUser; const headers = new Headers(); const setCookie = (name: string, value: string) => { const current = headers.get("cookie"); @@ -173,8 +213,8 @@ export async function getTestInstance< }; //@ts-expect-error const { data, error } = await client.signIn.email({ - email: testUser.email, - password: testUser.password, + email: user.email, + password: user.password, fetchOptions: { //@ts-expect-error onSuccess(context) { @@ -225,6 +265,22 @@ export async function getTestInstance< return auth.handler(req); }; + const changeUserRole = async (id: string, role: string) => { + await (await auth.$context).adapter.update({ + model: "user", + update: { + role, + }, + where: [ + { + field: "id", + operator: "eq", + value: id, + }, + ], + }); + }; + function sessionSetter(headers: Headers) { return (context: SuccessContext) => { const header = context.response.headers.get("set-cookie"); @@ -246,12 +302,16 @@ export async function getTestInstance< customFetchImpl, }, }); + return { auth, client, testUser, + adminUser, signInWithTestUser, + signInWithAdminUser: () => signInWithTestUser("admin"), signInWithUser, + changeUserRole, cookieSetter: setCookieToHeader, customFetchImpl, sessionSetter, diff --git a/packages/plugins/shutdown/.gitignore b/packages/plugins/shutdown/.gitignore new file mode 100644 index 0000000..5d2489a --- /dev/null +++ b/packages/plugins/shutdown/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +.env +.env.local +node_modules +dist \ No newline at end of file diff --git a/packages/plugins/shutdown/.npmignore b/packages/plugins/shutdown/.npmignore new file mode 100644 index 0000000..12d84e3 --- /dev/null +++ b/packages/plugins/shutdown/.npmignore @@ -0,0 +1,3 @@ +build.ts +.turbo +src \ No newline at end of file diff --git a/packages/plugins/shutdown/LICENSE b/packages/plugins/shutdown/LICENSE new file mode 100644 index 0000000..5a27647 --- /dev/null +++ b/packages/plugins/shutdown/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 - present, ping-maxwell + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/plugins/shutdown/README.md b/packages/plugins/shutdown/README.md new file mode 100644 index 0000000..ef373d2 --- /dev/null +++ b/packages/plugins/shutdown/README.md @@ -0,0 +1,37 @@ +# Shutdown Plugin for [Better Auth](https://github.com/better-auth/better-auth) + +This plugin allows you to stop signins or signups at any moment using rules. + +## Installation + +```bash +npm install @better-auth-kit/shutdown +``` + +## Usage + +```ts +import { shutdown } from "@better-auth-kit/shutdown"; + +export const auth = betterAuth({ + plugins: [ + shutdown({ + allowedRoles: ["admin"] + }), + ], +}); +``` + +## Documentation + +Read our documentation at [better-auth-kit.com](https://better-auth-kit.com/docs/plugins/shutdown). + +## What does it do? + +When enabled, the plugin will allow you to stop signins or signups at any moment. + +You can create rules that will be checked before the sign-in or sign-up process. If a rule is matched, the user will not be able to sign in or sign up. + +## License + +[MIT](LICENSE) diff --git a/packages/plugins/shutdown/build-dev.ts b/packages/plugins/shutdown/build-dev.ts new file mode 100644 index 0000000..2ab2d1d --- /dev/null +++ b/packages/plugins/shutdown/build-dev.ts @@ -0,0 +1,4 @@ +import { buildDev } from "@better-auth-kit/internal-build"; +import { config } from "./build"; + +buildDev(config); diff --git a/packages/plugins/shutdown/build.ts b/packages/plugins/shutdown/build.ts new file mode 100644 index 0000000..8425d0c --- /dev/null +++ b/packages/plugins/shutdown/build.ts @@ -0,0 +1,6 @@ +import { build, type Config } from "@better-auth-kit/internal-build"; + +export const config: Config = { + enableDts: true, +}; +build(config); diff --git a/packages/plugins/shutdown/package.json b/packages/plugins/shutdown/package.json new file mode 100644 index 0000000..700bafa --- /dev/null +++ b/packages/plugins/shutdown/package.json @@ -0,0 +1,42 @@ +{ + "name": "@better-auth-kit/shutdown", + "version": "0.0.1", + "description": "A plugin for better-auth that allows you to stop signins or signups at any moment.", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/ping-maxwell/better-auth-kit" + }, + "exports": { + ".": { + "default": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "dev": "bun build-dev.ts", + "build": "bun build.ts", + "test": "vitest" + }, + "keywords": ["better-auth", "shutdown", "plugin"], + "author": "ping-maxwell", + "contributors": [ + { + "name": "Francesco Saverio Cannizzaro", + "url": "https://github.com/fcannizzaro" + } + ], + "license": "MIT", + "devDependencies": { + "@better-auth-kit/internal-build": "workspace:*" + }, + "peerDependencies": { + "better-auth": "^1.2.1" + }, + "dependencies": { + "zod": "^3.24.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugins/shutdown/src/index.ts b/packages/plugins/shutdown/src/index.ts new file mode 100644 index 0000000..30e8aaf --- /dev/null +++ b/packages/plugins/shutdown/src/index.ts @@ -0,0 +1,329 @@ +import type { Adapter, BetterAuthPlugin } from "better-auth"; +import { + APIError, + createAuthEndpoint, + createAuthMiddleware, + sessionMiddleware, +} from "better-auth/api"; + +export interface Rule { + id: string; + roles: string[]; + signIn?: boolean; + signUp?: boolean; + from?: number; + to?: number; +} + +import { z } from "zod"; + +export interface RevalidationOptions { + /** + * The function that should be called to revalidate the rules. + */ + requireRevalidation: (cb: () => void) => any; + /** + * The function that will be called when the rules are changed. + */ + onRulesChanged: () => any; +} + +export interface ShutdownOptions { + /** + * The roles that are allowed to list/create/remove shutdown rules. + * @default ["admin"] + */ + allowedRoles?: string[]; + + /** + * The adapter to use for external storage (e.g. Redis) to revalidate the rules. + * @default undefined + */ + cache?: RevalidationOptions; +} + +export const ERROR_CODES = { + SIGNIN_DISABLED: "Sign-in is disabled", + SIGNUP_DISABLED: "Sign-up is disabled", +} as const; + +export const shutdown = ({ + allowedRoles = ["admin"], + cache, +}: ShutdownOptions) => { + const rules: Rule[] = []; + let latest = false; + + cache?.requireRevalidation(() => { + latest = false; + }); + + const sync = async () => { + cache?.onRulesChanged(); + latest = false; + }; + + const getRules = async (adapter: Adapter) => { + if (!latest) { + const results = await adapter.findMany({ + model: "shutdown-rules", + where: [], + }); + rules.length = 0; + rules.push( + ...results.map((r) => ({ + ...r, + roles: r.roles.split(",").filter(Boolean), + })), + ); + latest = true; + } + return rules; + }; + + const testRules = async ({ + type, + adapter, + role, + message, + }: { + type: "signIn" | "signUp"; + role?: string; + adapter: Adapter; + message: string; + }) => { + const rules = await getRules(adapter); + const today = Date.now(); + + const isDisabled = rules.some(({ roles, from, to, ...rule }) => { + let block = !rule[type]; + + if (roles.length && role) { + block &&= roles.includes(role); + } + + if (from) { + block &&= block && from < today; + } + + if (to) { + block &&= block && to > today; + } + + return block; + }); + + if (isDisabled) { + throw new APIError("BAD_REQUEST", { + message, + }); + } + }; + + return { + id: "shutdown", + endpoints: { + listShutdownRules: createAuthEndpoint( + "/shutdown", + { + method: "GET", + use: [sessionMiddleware], + }, + async (ctx) => { + const session = ctx.context.session; + if (!session || !allowedRoles.includes(session.user.role)) { + return ctx.json([]); + } + return ctx.json(await getRules(ctx.context.adapter)); + }, + ), + isSignupAllowed: createAuthEndpoint( + "/shutdown/is-signup-allowed", + { + method: "GET", + }, + async (ctx) => { + try { + await testRules({ + type: "signUp", + adapter: ctx.context.adapter, + message: ERROR_CODES.SIGNUP_DISABLED, + }); + return ctx.json({ allowed: true }); + } catch (error) { + return ctx.json({ allowed: false }); + } + }, + ), + isSignInAllowed: createAuthEndpoint( + "/shutdown/is-signin-allowed", + { + method: "GET", + }, + async (ctx) => { + try { + await testRules({ + type: "signIn", + adapter: ctx.context.adapter, + message: ERROR_CODES.SIGNIN_DISABLED, + }); + return ctx.json({ allowed: true }); + } catch (error) { + return ctx.json({ allowed: false }); + } + }, + ), + createShutdownRule: createAuthEndpoint( + "/shutdown/create", + { + method: "POST", + use: [sessionMiddleware], + body: z.object({ + roles: z + .array(z.string()) + .default([]) + .transform((it) => it.join(",")), + signIn: z.boolean().default(true), + signUp: z.boolean().default(true), + from: z.date().nullish().default(null), + to: z.date().nullish().default(null), + }), + }, + async (ctx) => { + const session = ctx.context.session; + if (!session || !allowedRoles.includes(session.user.role)) { + return ctx.json({ success: false }); + } + + const rule = await ctx.context.adapter.create({ + model: "shutdown-rules", + data: { + id: ctx.context.generateId({ + model: "shutdown-rules", + }), + ...ctx.body, + }, + }); + + sync(); + + return ctx.json({ success: true, ruleId: rule.id }); + }, + ), + removeShutdownRule: createAuthEndpoint( + "/shutdown/remove", + { + method: "POST", + use: [sessionMiddleware], + body: z.object({ + id: z.string(), + }), + }, + async (ctx) => { + const session = ctx.context.session; + + if (!session || !allowedRoles.includes(session.user.role)) { + return ctx.json({ success: false }); + } + + await ctx.context.adapter.delete({ + model: "shutdown-rules", + where: [ + { + field: "id", + operator: "eq", + value: ctx.body.id, + }, + ], + }); + + sync(); + + return ctx.json({ success: true }); + }, + ), + }, + hooks: { + before: [ + { + matcher: (context) => context.path.startsWith("/sign-in/email"), + handler: createAuthMiddleware(async (ctx) => { + const user = await ctx.context.adapter.findOne<{ role: string }>({ + model: "user", + select: ["role"], + where: [ + { + field: "email", + operator: "eq", + value: ctx.body.email, + }, + ], + }); + if (!user?.role || !allowedRoles.includes(user.role)) { + await testRules({ + type: "signIn", + role: user?.role, + adapter: ctx.context.adapter, + message: ERROR_CODES.SIGNIN_DISABLED, + }); + } + return { context: ctx }; + }), + }, + { + matcher: (context) => context.path.startsWith("/sign-up/email"), + handler: createAuthMiddleware(async (ctx) => { + await testRules({ + type: "signUp", + adapter: ctx.context.adapter, + message: ERROR_CODES.SIGNUP_DISABLED, + }); + return { context: ctx }; + }), + }, + ], + }, + schema: { + "shutdown-rules": { + fields: { + roles: { + type: "string", + fieldName: "roles", + input: false, + required: false, + defaultValue: null, + }, + signIn: { + type: "boolean", + fieldName: "sign-in", + input: false, + required: false, + defaultValue: true, + }, + signUp: { + type: "boolean", + fieldName: "sign-up", + input: false, + required: false, + defaultValue: true, + }, + from: { + type: "date", + fieldName: "from", + input: false, + required: false, + defaultValue: null, + }, + to: { + type: "date", + fieldName: "to", + input: false, + required: false, + defaultValue: null, + }, + }, + }, + }, + $ERROR_CODES: ERROR_CODES, + } satisfies BetterAuthPlugin; +}; diff --git a/packages/plugins/shutdown/tests/shutdown.test.ts b/packages/plugins/shutdown/tests/shutdown.test.ts new file mode 100644 index 0000000..890ba34 --- /dev/null +++ b/packages/plugins/shutdown/tests/shutdown.test.ts @@ -0,0 +1,321 @@ +import { describe, it, expect } from "vitest"; +import { getTestInstance } from "@better-auth-kit/tests"; +import { ERROR_CODES, type RevalidationOptions, shutdown } from "../src/index"; +import { EventEmitter } from "node:events"; + +describe("shutdown plugin (rules management)", async () => { + const { auth, signInWithAdminUser } = await getTestInstance({ + plugins: [ + shutdown({ + allowedRoles: ["admin"], + }), + ], + }); + + it("Should create rules", async () => { + const { headers } = await signInWithAdminUser(); + + const signupRule = await auth.api.createShutdownRule({ + body: { + signUp: false, + }, + headers, + }); + expect(signupRule.success).toBeTruthy(); + + const signInRule = await auth.api.createShutdownRule({ + body: { + signIn: false, + }, + headers, + }); + expect(signInRule.success).toBeTruthy(); + }); + + it("Should list rules", async () => { + const { headers } = await signInWithAdminUser(); + const rules = await auth.api.listShutdownRules({ + headers, + }); + expect(rules).toHaveLength(2); + }); + + it("Should remove rules", async () => { + const { headers } = await signInWithAdminUser(); + const rules = await auth.api.listShutdownRules({ + headers, + }); + + const results = await Promise.all( + rules.map((rule) => + auth.api.removeShutdownRule({ + body: { + id: rule.id, + }, + headers, + }), + ), + ); + + expect(results).toHaveLength(rules.length); + expect(results.every((result) => result.success)).toBeTruthy(); + + // Check if the rules are removed + const nowRules = await auth.api.listShutdownRules({ + headers, + }); + expect(nowRules).toHaveLength(0); + }); +}); + +describe("shutdown plugin (by role)", async () => { + const { auth, testUser, signInWithAdminUser } = await getTestInstance({ + plugins: [ + shutdown({ + allowedRoles: ["admin"], + }), + ], + }); + + it("Should create multiple shutdown rules", async () => { + const { headers } = await signInWithAdminUser(); + + const signInRule = await auth.api.createShutdownRule({ + body: { + roles: ["user"], + signIn: false, + }, + headers, + }); + expect(signInRule.success).toBeTruthy(); + }); + + it("Should disallow the user sign-in", async () => { + await expect(() => + auth.api.signInEmail({ body: testUser }), + ).rejects.toThrowError(ERROR_CODES.SIGNIN_DISABLED); + }); +}); + +describe("shutdown plugin (by time)", async () => { + const { auth, testUser, signInWithAdminUser } = await getTestInstance({ + plugins: [ + shutdown({ + allowedRoles: ["admin"], + }), + ], + }); + + it("Should create multiple shutdown rules", async () => { + const { headers } = await signInWithAdminUser(); + + const signupRule = await auth.api.createShutdownRule({ + body: { + signUp: false, + // disabled since the last 30 minutes + from: new Date(Date.now() - 1000 * 60 * 30), + // disabled until the next 30 minutes + to: new Date(Date.now() + 1000 * 60 * 30), + }, + headers, + }); + expect(signupRule.success).toBeTruthy(); + + const signInRule = await auth.api.createShutdownRule({ + body: { + signIn: false, + // disabled since the last 30 minutes + from: new Date(Date.now() - 1000 * 60 * 30), + // disabled until the next 30 minutes + to: new Date(Date.now() + 1000 * 60 * 30), + }, + headers, + }); + expect(signInRule.success).toBeTruthy(); + }); + + it("Should disallow the user sign-up", async () => { + await expect(() => + auth.api.signUpEmail({ body: testUser }), + ).rejects.toThrowError(ERROR_CODES.SIGNUP_DISABLED); + }); + + it("Should disallow the user sign-in (from)", async () => { + await expect(() => + auth.api.signInEmail({ body: testUser }), + ).rejects.toThrowError(ERROR_CODES.SIGNIN_DISABLED); + }); +}); + +describe("shutdown plugin (by multiple conditions)", async () => { + const { auth, testUser, adminUser, signInWithAdminUser, changeUserRole } = + await getTestInstance({ + plugins: [ + shutdown({ + allowedRoles: ["admin"], + }), + ], + }); + + const otherUser = { + email: "other@other.com", + name: "other user", + password: "other123456", + }; + + it("Should allow a user to sign-up", async () => { + const user = await auth.api.signUpEmail({ + body: otherUser, + }); + + expect(user.user).toMatchObject({ + email: otherUser.email, + name: otherUser.name, + }); + + // change user role from 'user' to 'other' + await changeUserRole(user.user.id, "other"); + }); + + it("isSignupAllowed() should return true", async () => { + expect(await auth.api.isSignupAllowed()).toStrictEqual({ + allowed: true, + }); + }); + + it("isSignInAllowed() should return true", async () => { + expect(await auth.api.isSignInAllowed()).toStrictEqual({ + allowed: true, + }); + }); + + it("Should create multiple shutdown rules", async () => { + const { headers } = await signInWithAdminUser(); + + const rule = await auth.api.createShutdownRule({ + body: { + signUp: false, + signIn: false, + // disable only for the user role + roles: ["user"], + // disabled since the last 30 minutes + from: new Date(Date.now() - 1000 * 60 * 30), + // disabled until the next 30 minutes + to: new Date(Date.now() + 1000 * 60 * 30), + }, + headers, + }); + expect(rule.success).toBeTruthy(); + }); + + it("Should disallow the user sign-up", async () => { + await expect(() => + auth.api.signUpEmail({ body: testUser }), + ).rejects.toThrowError(ERROR_CODES.SIGNUP_DISABLED); + }); + + it("isSignupAllowed() should return false", async () => { + expect(await auth.api.isSignupAllowed()).toStrictEqual({ + allowed: false, + }); + }); + + it("isSignInAllowed() should return false", async () => { + expect(await auth.api.isSignInAllowed()).toStrictEqual({ + allowed: false, + }); + }); + + it("Should disallow any user sign-in (still allowing 'admin'/'other' roles)", async () => { + // allow admin user to sign in + expect(await auth.api.signInEmail({ body: adminUser })).toMatchObject({ + user: { + email: adminUser.email, + }, + }); + // allow otherUser user to sign in + expect(await auth.api.signInEmail({ body: otherUser })).toMatchObject({ + user: { + email: otherUser.email, + }, + }); + // disallow any other user sign-in + await expect(() => + auth.api.signInEmail({ body: testUser }), + ).rejects.toThrowError(ERROR_CODES.SIGNIN_DISABLED); + }); +}); + +describe("shutdown plugin (external cache revalidation)", async () => { + const ev = new EventEmitter(); + + // This a sample cache implementation using only EventEmitter + const cache: RevalidationOptions = { + requireRevalidation: (cb) => ev.on("shutdown:sync", cb), + onRulesChanged: () => ev.emit("shutdown:sync"), + }; + + const { auth, signInWithAdminUser } = await getTestInstance({ + plugins: [ + shutdown({ + allowedRoles: ["admin"], + cache, + }), + ], + }); + + it("Should create rules", async () => { + const { headers } = await signInWithAdminUser(); + + const signupRule = await auth.api.createShutdownRule({ + body: { + signUp: false, + }, + headers, + }); + expect(signupRule.success).toBeTruthy(); + + const signInRule = await auth.api.createShutdownRule({ + body: { + signIn: false, + }, + headers, + }); + expect(signInRule.success).toBeTruthy(); + }); + + it("Should list rules", async () => { + const { headers } = await signInWithAdminUser(); + const rules = await auth.api.listShutdownRules({ + headers, + }); + expect(rules).toHaveLength(2); + }); + + it("Should remove rules", async () => { + const { headers } = await signInWithAdminUser(); + const rules = await auth.api.listShutdownRules({ + headers, + }); + + const results = await Promise.all( + rules.map((rule) => + auth.api.removeShutdownRule({ + body: { + id: rule.id, + }, + headers, + }), + ), + ); + + expect(results).toHaveLength(rules.length); + expect(results.every((result) => result.success)).toBeTruthy(); + + // Check if the rules are removed + const nowRules = await auth.api.listShutdownRules({ + headers, + }); + expect(nowRules).toHaveLength(0); + }); +}); diff --git a/packages/plugins/shutdown/tsconfig.json b/packages/plugins/shutdown/tsconfig.json new file mode 100644 index 0000000..dfb4375 --- /dev/null +++ b/packages/plugins/shutdown/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "declaration": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["./src/*"], + "exclude": ["dist", "build.ts"] +}