Defining custom rules
The Arcjet SDK can be augmented with custom rules written by users, as long as
they match the format defined by our @arcjet/protocol
package. These rules
will only be run locally since the Arcjet service doesn’t know about them;
however, they can still be useful for some use cases.
The structure of a rule is:
interface ArcjetRule<Props extends { [key: string]: unknown } = {}> { type: string; mode: "LIVE" | "DRY_RUN"; priority: number; validate( context: ArcjetContext, details: Partial<ArcjetRequestDetails & Props>, ): asserts details is ArcjetRequestDetails & Props; protect( context: ArcjetContext, details: ArcjetRequestDetails & Props, ): Promise<ArcjetRuleResult>;}
For example, you may want to validate form input as part of your Arcjet protections before a request reaches your route handler, such as via Next.js middleware.
Custom rule: Zod + Body validation
Section titled “Custom rule: Zod + Body validation”If we want to run Zod schema validation against the request body, we need to create a new custom rule:
import { ArcjetRuleResult, ArcjetErrorReason, ArcjetReason,} from "@arcjet/protocol";import type { ArcjetRule, ArcjetContext, ArcjetRequestDetails,} from "@arcjet/protocol";import { webcrypto } from "node:crypto";import { z } from "zod";import { fromError } from "zod-validation-error";
// Create a subclass of ArcjetReason to store extra informationclass LocalDataValidation extends ArcjetReason { error?: string;
constructor({ error }: { error?: string } = {}) { super(); if (error) { this.error = error; } }}
// Create a constructor for building multiple instances of the rulefunction validateBody(options: { // Each instance will run in LIVE or DRY_RUN mode mode: "LIVE" | "DRY_RUN"; // Each instance will validate using a Zod schema schema: z.Schema;}) {
// Note that `ruleId` is only used for caching purposes. In // this case we want to validate the body on every request, // so we provide a new random UUID for each instance. const ruleId = webcrypto.randomUUID();
return [ <ArcjetRule<{}>>{ version: 1, type: "DATA_VALIDATION", mode: options.mode, priority: 0, validate( context: ArcjetContext, details: ArcjetRequestDetails, ): asserts details is ArcjetRequestDetails {},
async protect( context: ArcjetContext, details: ArcjetRequestDetails, ): Promise<ArcjetRuleResult> {
try { const body = await context.getBody(); if (typeof body !== "string") { return new ArcjetRuleResult({ ruleId, ttl: 0, state: "NOT_RUN", conclusion: "ALLOW", reason: new LocalDataValidation({ error: "Missing body", }), fingerprint: context.fingerprint, }); }
const json = JSON.parse(body); const result = options.schema.safeParse(json);
if (result.success) { return new ArcjetRuleResult({ ruleId, ttl: 0, state: "RUN", conclusion: "ALLOW", reason: new LocalDataValidation(), fingerprint: context.fingerprint, }); } else { return new ArcjetRuleResult({ ruleId, ttl: 0, state: "RUN", conclusion: "DENY", reason: new LocalDataValidation({ error: fromError(result.error).toString(), }), fingerprint: context.fingerprint, }); } } catch (err) { return new ArcjetRuleResult({ ruleId, ttl: 0, state: "NOT_RUN", conclusion: "ERROR", reason: new ArcjetErrorReason(err), fingerprint: context.fingerprint, }); } }, }, ];}
As long as it conforms to the rule interface, this rule can be consumed by the Arcjet SDK like any other rule!
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ validateBody({ mode: "LIVE", schema: z.object({ email: z.string(), }), }), ],});
When aj.protect()
is called, inside middleware or directly inside a route,
this custom rule will be executed and deny the request if the body doesn’t
pass validation.