@danielbiegler/vendure-plugin-user-registration-guard
Version:
Let's you flexibly customize if and how you prevent users from registering with your Vendure instance.
327 lines (326 loc) • 14.9 kB
JavaScript
;
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var UserRegistrationGuardPlugin_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.UserRegistrationGuardPlugin = exports.UserRegistrationInterceptor = void 0;
const common_1 = require("@nestjs/common");
const core_1 = require("@nestjs/core");
const graphql_1 = require("@nestjs/graphql");
const core_2 = require("@vendure/core");
const rxjs_1 = require("rxjs");
const constants_1 = require("./constants");
const user_registration_blocked_event_1 = require("./events/user-registration-blocked.event");
/**
* Since this interceptor will be registered globally it will run between every single request.
* This means we must make sure to exit as early as possible in order to not slow down the Vendure instance.
*/
let UserRegistrationInterceptor = class UserRegistrationInterceptor {
eventBus;
moduleRef;
requestContextService;
options;
injector;
constructor(eventBus, moduleRef, requestContextService, options) {
this.eventBus = eventBus;
this.moduleRef = moduleRef;
this.requestContextService = requestContextService;
this.options = options;
}
/** @internal */
async onApplicationBootstrap() {
this.injector = new core_2.Injector(this.moduleRef);
}
/** @internal */
async _isAllowed(ctx, assertFunctions, args) {
/**
* This should never happen, because Nestjs initializes the injector via `this.onApplicationBootstrap`,
* but TypeScript can't guarantee that the value of `this.injector` won't change between checking and execution of the closure.
* Each closure creates a new context where previous type narrowing isn't automatically carried forward.
* Capturing the injector locally like this let's us carry it into the closure in a type-safe manner,
* without using non-null assertion operator.
*/
if (!this.injector)
throw new Error("Injector is not initialized");
const _capturedInjector = this.injector;
const promises = assertFunctions.map((f) => f(ctx, args, _capturedInjector));
const results = await Promise.allSettled(promises);
const rejecteds = results.filter((r) => r.status === "rejected");
if (rejecteds.length !== 0)
throw new Error(`${rejecteds.length}/${results.length} AssertionFunctions rejected. This should never happen. Handle errors in your assertions.`, { cause: rejecteds });
// Typescript needed a little help here (2025-04-25)
// The compiler failed because it couldnt infer the fulfilled promises and thought its `PromiseSettledResult<AssertFunctionResult>`
const fulfilleds = results.filter((r) => r.status === "fulfilled");
const failures = fulfilleds.filter((f) => f.value.isAllowed === false);
const operator = ctx.apiType === "shop" ? this.options.shop.assert.logicalOperator : this.options.admin.assert.logicalOperator;
switch (operator) {
case "AND": // In a logical "AND" everything must be true
return { isAllowed: failures.length === 0, results: fulfilleds.map((f) => f.value) };
case "OR": // In a logical "OR" at least one must be true
return { isAllowed: failures.length !== fulfilleds.length, results: fulfilleds.map((f) => f.value) };
default:
throw new Error(`Unknown "logicalOperator" (${this.options.shop.assert.logicalOperator}) - This should never happen.`);
}
}
/** @internal */
async intercept(context, next) {
// Irrelevant contexts
if (context.getType() !== "graphql")
return next.handle();
const gqlCtx = graphql_1.GqlExecutionContext.create(context);
const info = gqlCtx.getInfo();
if (info?.path?.typename !== "Mutation")
return next.handle();
const requestContext = await this.requestContextService.fromRequest(gqlCtx.getContext().req, info);
// Check if we can exit early
switch (requestContext.apiType) {
case "shop":
if (this.options.shop.assert.functions.length === 0)
return next.handle();
break;
case "admin":
if (this.options.admin.assert.functions.length === 0)
return next.handle();
break;
default:
return next.handle();
}
let result;
let args;
switch (info?.path?.key) {
case "registerCustomerAccount":
args = gqlCtx.getArgs();
result = await this._isAllowed(requestContext, this.options.shop.assert.functions, args);
break;
case "createAdministrator":
args = gqlCtx.getArgs();
result = await this._isAllowed(requestContext, this.options.admin.assert.functions, args);
break;
default:
return next.handle();
}
if (!result.isAllowed) {
return new rxjs_1.Observable((s) => {
if (requestContext.apiType === "shop") {
// Registering doesnt throw but returns a union
s.next(new core_2.NativeAuthStrategyError());
this.eventBus
.publish(new user_registration_blocked_event_1.BlockedCustomerRegistrationEvent(requestContext, args, result.results))
.catch(() => { });
}
else {
// CreateAdmin Mutation normally only returns `Administrator`, so we throw
s.error(new core_2.NativeAuthStrategyError());
this.eventBus
.publish(new user_registration_blocked_event_1.BlockedCreateAdministratorEvent(requestContext, args, result.results))
.catch(() => { });
}
s.complete();
});
}
return next.handle();
}
};
exports.UserRegistrationInterceptor = UserRegistrationInterceptor;
exports.UserRegistrationInterceptor = UserRegistrationInterceptor = __decorate([
(0, common_1.Injectable)(),
__param(3, (0, common_1.Inject)(constants_1.PLUGIN_INIT_OPTIONS)),
__metadata("design:paramtypes", [core_2.EventBus,
core_1.ModuleRef,
core_2.RequestContextService, Object])
], UserRegistrationInterceptor);
/**
* The UserRegistrationGuardPlugin let's you flexibly customize if and how you prevent users from registering with your Vendure instance.
*
* ```ts
* import { UserRegistrationGuardPlugin } from "@danielbiegler/vendure-plugin-user-registration-guard";
* export const config: VendureConfig = {
* // ...
* plugins: [
* UserRegistrationGuardPlugin.init({
* shop: {
* assert: {
* // AND means every single assertion must be true to allow user registration
* logicalOperator: "AND",
* functions: [ ], // Insert your assertions here
* },
* },
* admin: {
* assert: {
* // OR means user registration is allowed if a single assertion is true
* logicalOperator: "OR",
* functions: [ ], // You may leave this empty too
* },
* },
* }),
* ],
* }
* ```
*
* Please refer to the specific {@link PluginUserRegistrationGuardOptions} for how and what you can customize.
*
* ### 2. Create an assertion
*
* Here's an example assertion where we block customer registrations if the email ends with `example.com`:
*
* ```ts
* const blockExampleDotCom: AssertFunctionShopApi = async (ctx, args) => {
* const isAllowed = !args.input.emailAddress.endsWith("example.com");
* return {
* isAllowed,
* reason: !isAllowed ? 'Failed because email ends with "example.com"' : undefined,
* };
* };
* ```
*
* The `reason` field is helpful for when you're subscribing to the published {@link BlockedCustomerRegistrationEvent} or {@link BlockedCreateAdministratorEvent} and want to log or understand why somebody got blocked.
*
* In your assertions you'll receive the [`RequestContext`](https://docs.vendure.io/reference/typescript-api/request/request-context) and the GraphQL arguments of the mutation, which by default are either [`RegisterCustomerInput`](https://docs.vendure.io/reference/graphql-api/shop/input-types#registercustomerinput) or [`CreateAdministratorInput`](https://docs.vendure.io/reference/graphql-api/admin/input-types#createadministratorinput) depending on the API type. For example, if you'd like to block IP ranges you can access the underlying [Express Request](https://docs.vendure.io/reference/typescript-api/request/request-context#req) object through the `RequestContext` .
*
* If you've extended your GraphQL API types you may override the TypeScript Generic to get completions in your assertion functions like so:
*
* ```ts
* const example: AssertFunctionShopApi<{
* example: boolean;
* // ...
* }> = async (ctx, args) => {
* return { isAllowed: args.example };
* };
* ```
*
* ### 3. Add it to the plugin
*
* ```ts
* import { UserRegistrationGuardPlugin } from "@danielbiegler/vendure-plugin-user-registration-guard";
* export const config: VendureConfig = {
* // ...
* plugins: [
* UserRegistrationGuardPlugin.init({
* shop: {
* assert: {
* logicalOperator: "AND",
* functions: [ blockExampleDotCom ],
* },
* },
* admin: {
* assert: {
* logicalOperator: "AND",
* functions: [],
* },
* },
* }),
* ],
* }
* ```
*
* ### 4. Try registering new customers
*
* ```graphql
* mutation {
* registerCustomerAccount(input: {
* emailAddress: "example@example.com",
* # ...
* }) {
* __typename
* }
* }
* ```
*
* This user will now be blocked from registering according to our `blockExampleDotCom` assertion.
*
* #### Handling errors
*
* The plugin is non-intrusive and does not extend the API itself to avoid introducing unhandled errors in your code.
*
* It respects [`RegisterCustomerAccountResult`](https://docs.vendure.io/reference/graphql-api/shop/object-types#registercustomeraccountresult) being a Union, so we don't throw an error, but return a [`NativeAuthStrategyError`](https://docs.vendure.io/reference/graphql-api/shop/object-types#nativeauthstrategyerror). You may handle the error just like the other `RegisterCustomerAccountResult` types like [`PasswordValidationError`](https://docs.vendure.io/reference/graphql-api/shop/object-types#passwordvalidationerror) for example.
*
* In contrast, for admins we do throw the error! This is a little different because by default the [`createAdministrator`](https://docs.vendure.io/reference/graphql-api/admin/mutations#createadministrator) mutation does not return a Union with error types.
*
* Granted, the `NativeAuthStrategyError` is technically not correct for blocking registrations and doesn't communicate the blocking properly, but it's the only reasonable error type in the Union for a default non-api-extended Vendure instance. You might want to add some comments in your registration logic that the error means blockage.
*
* ### 5. Subscribe to events
*
* You may want to [subscribe](https://docs.vendure.io/guides/developer-guide/events/#subscribing-to-events) to the [EventBus](https://docs.vendure.io/reference/typescript-api/events/event-bus) to monitor blocked registration attempts.
*
* ```ts
* this.eventBus
* .ofType(BlockedCustomerRegistrationEvent<MutationRegisterCustomerAccountArgs>)
* .subscribe(async (event) => {
* const rejecteds = event.assertions.filter((a) => !a.isAllowed);
* console.log(`Blocked customer registration! ${rejecteds.length}/${event.assertions.length} assertions failed, see reasons:`);
* rejecteds.forEach(r => console.log(" -", r.reason));
*
* // Example output:
* // Blocked customer registration! 1/1 assertions failed, see reasons:
* // - Failed because email ends with "example.com"
* });
*
* this.eventBus
* // You can even override the passed in args if you've extended your Graphql API
* .ofType(BlockedCreateAdministratorEvent<{ example: boolean }>).subscribe(async (event) => {
* event.args.example // is typed now! :)
* });
* ```
*
* @category Plugin
*/
let UserRegistrationGuardPlugin = class UserRegistrationGuardPlugin {
static { UserRegistrationGuardPlugin_1 = this; }
/** @internal */
static options;
/**
* The static `init()` method is called with the options to configure the plugin.
*
* @example
* ```ts
* UserRegistrationGuardPlugin.init({
* shop: {
* assert: {
* logicalOperator: "AND",
* functions: [firstFunc, secondFunc],
* },
* },
* admin: {
* assert: {
* logicalOperator: "OR",
* functions: [],
* },
* },
* }),
* ```
*/
static init(options) {
this.options = options;
return UserRegistrationGuardPlugin_1;
}
};
exports.UserRegistrationGuardPlugin = UserRegistrationGuardPlugin;
exports.UserRegistrationGuardPlugin = UserRegistrationGuardPlugin = UserRegistrationGuardPlugin_1 = __decorate([
(0, core_2.VendurePlugin)({
imports: [core_2.PluginCommonModule],
providers: [
{
provide: constants_1.PLUGIN_INIT_OPTIONS,
useFactory: () => UserRegistrationGuardPlugin.options,
},
{
provide: core_1.APP_INTERCEPTOR,
useClass: UserRegistrationInterceptor,
},
],
configuration: (config) => {
return config;
},
compatibility: ">=3.0.0",
})
], UserRegistrationGuardPlugin);