From 216d9c47fa212bbdd23960b69241395f51a28a3a Mon Sep 17 00:00:00 2001 From: Blaine Bublitz Date: Thu, 22 Feb 2024 10:24:38 -0700 Subject: [PATCH] withRule in next sdk and docs --- arcjet-next/index.ts | 229 ++++++++++++++++++++++++------------------- arcjet/index.ts | 7 ++ 2 files changed, 135 insertions(+), 101 deletions(-) diff --git a/arcjet-next/index.ts b/arcjet-next/index.ts index 48d5b5025..0fcf9df8f 100644 --- a/arcjet-next/index.ts +++ b/arcjet-next/index.ts @@ -21,6 +21,7 @@ import arcjet, { RemoteClientOptions, defaultBaseUrl, createRemoteClient, + Arcjet, } from "arcjet"; import findIP from "@arcjet/ip"; @@ -160,6 +161,10 @@ function cookiesToString(cookies?: ArcjetNextRequest["cookies"]): string { .join("; "); } +/** + * The ArcjetNext client provides a public `protect()` method to + * make a decision about how a Next.js request should be handled. + */ export interface ArcjetNext { get runtime(): Runtime; /** @@ -178,124 +183,146 @@ export interface ArcjetNext { // that is required if the ExtraProps aren't strictly an empty object ...props: Props extends WithoutCustomProps ? [] : [Props] ): Promise; -} -/** - * This is the main class for Arcjet when using Next.js. It provides several - * methods for protecting Next.js routes depending on whether they are using the - * Edge or Serverless Functions runtime. - */ -/** - * Create a new Arcjet Next client. If possible, call this outside of the - * request context so it persists across requests. - * - * @param key - The key to identify the site in Arcjet. - * @param options - Arcjet configuration options to apply to all requests. - * These can be overriden on a per-request basis by providing them to the - * `protect()` or `protectApi` methods. - */ -export default function arcjetNext( - options: ArcjetOptions, -): ArcjetNext>> { - const client = options.client ?? createNextRemoteClient(); + /** + * Augments the client with another rule. Useful for varying rules based on + * criteria in your handler—e.g. different rate limit for logged in users. + * + * @param rule The rule to add to this execution. + * @returns An augmented {@link ArcjetNext} client. + */ + withRule( + rule: Rule, + ): ArcjetNext>>; +} - const aj = arcjet({ ...options, client }); +function toArcjetRequest( + request: ArcjetNextRequest, + props: Props, +): ArcjetRequest { + // We construct an ArcjetHeaders to normalize over Headers + const headers = new ArcjetHeaders(request.headers); + + const ip = findIP(request, headers); + const method = request.method ?? ""; + const host = headers.get("host") ?? ""; + let path = ""; + let query = ""; + let protocol = ""; + // TODO(#36): nextUrl has formatting logic when you `toString` but + // we don't account for that here + if (typeof request.nextUrl !== "undefined") { + path = request.nextUrl.pathname ?? ""; + if (typeof request.nextUrl.search !== "undefined") { + query = request.nextUrl.search; + } + if (typeof request.nextUrl.protocol !== "undefined") { + protocol = request.nextUrl.protocol; + } + } else { + if (typeof request.socket?.encrypted !== "undefined") { + protocol = request.socket.encrypted ? "https:" : "http:"; + } else { + protocol = "http:"; + } + // Do some very simple validation, but also try/catch around URL parsing + if ( + typeof request.url !== "undefined" && + request.url !== "" && + host !== "" + ) { + try { + const url = new URL(request.url, `${protocol}//${host}`); + path = url.pathname; + query = url.search; + protocol = url.protocol; + } catch { + // If the parsing above fails, just set the path as whatever url we + // received. + // TODO(#216): Add logging to arcjet-next + path = request.url ?? ""; + } + } else { + path = request.url ?? ""; + } + } + const cookies = cookiesToString(request.cookies); + + const extra: { [key: string]: string } = {}; + + // If we're running on Vercel, we can add some extra information + if (process.env["VERCEL"]) { + // Vercel ID https://vercel.com/docs/concepts/edge-network/headers + extra["vercel-id"] = headers.get("x-vercel-id") ?? ""; + // Vercel deployment URL + // https://vercel.com/docs/concepts/edge-network/headers + extra["vercel-deployment-url"] = + headers.get("x-vercel-deployment-url") ?? ""; + // Vercel git commit SHA + // https://vercel.com/docs/concepts/projects/environment-variables/system-environment-variables + extra["vercel-git-commit-sha"] = process.env["VERCEL_GIT_COMMIT_SHA"] ?? ""; + extra["vercel-git-commit-sha"] = process.env["VERCEL_GIT_COMMIT_SHA"] ?? ""; + } + return { + ...props, + ...extra, + ip, + method, + protocol, + host, + path, + headers, + cookies, + query, + }; +} +function withClient( + aj: Arcjet>, +): ArcjetNext> { return Object.freeze({ get runtime() { return aj.runtime; }, + withRule(rule: Primitive | Product) { + const client = aj.withRule(rule); + return withClient(client); + }, async protect( request: ArcjetNextRequest, ...[props]: ExtraProps extends WithoutCustomProps ? [] : [ExtraProps] ): Promise { - // We construct an ArcjetHeaders to normalize over Headers - const headers = new ArcjetHeaders(request.headers); - - const ip = findIP(request, headers); - const method = request.method ?? ""; - const host = headers.get("host") ?? ""; - let path = ""; - let query = ""; - let protocol = ""; - // TODO(#36): nextUrl has formatting logic when you `toString` but - // we don't account for that here - if (typeof request.nextUrl !== "undefined") { - path = request.nextUrl.pathname ?? ""; - if (typeof request.nextUrl.search !== "undefined") { - query = request.nextUrl.search; - } - if (typeof request.nextUrl.protocol !== "undefined") { - protocol = request.nextUrl.protocol; - } - } else { - if (typeof request.socket?.encrypted !== "undefined") { - protocol = request.socket.encrypted ? "https:" : "http:"; - } else { - protocol = "http:"; - } - // Do some very simple validation, but also try/catch around URL parsing - if ( - typeof request.url !== "undefined" && - request.url !== "" && - host !== "" - ) { - try { - const url = new URL(request.url, `${protocol}//${host}`); - path = url.pathname; - query = url.search; - protocol = url.protocol; - } catch { - // If the parsing above fails, just set the path as whatever url we - // received. - // TODO(#216): Add logging to arcjet-next - path = request.url ?? ""; - } - } else { - path = request.url ?? ""; - } - } - const cookies = cookiesToString(request.cookies); - - const extra: { [key: string]: string } = {}; - - // If we're running on Vercel, we can add some extra information - if (process.env["VERCEL"]) { - // Vercel ID https://vercel.com/docs/concepts/edge-network/headers - extra["vercel-id"] = headers.get("x-vercel-id") ?? ""; - // Vercel deployment URL - // https://vercel.com/docs/concepts/edge-network/headers - extra["vercel-deployment-url"] = - headers.get("x-vercel-deployment-url") ?? ""; - // Vercel git commit SHA - // https://vercel.com/docs/concepts/projects/environment-variables/system-environment-variables - extra["vercel-git-commit-sha"] = - process.env["VERCEL_GIT_COMMIT_SHA"] ?? ""; - extra["vercel-git-commit-sha"] = - process.env["VERCEL_GIT_COMMIT_SHA"] ?? ""; - } - - const decision = await aj.protect({ - ...props, - ip, - method, - protocol, - host, - path, - headers, - cookies, - query, - ...extra, - // TODO(#220): The generic manipulations get really mad here, so we just cast it - } as ArcjetRequest>); - - return decision; + // TODO(#220): The generic manipulations get really mad here, so we cast + // Further investigation makes it seem like it has something to do with + // the definition of `props` in the signature but it's hard to track down + const req = toArcjetRequest(request, props ?? {}) as ArcjetRequest< + ExtraProps + >; + + return aj.protect(req); }, }); } +/** + * Create a new Arcjet Next client. Always build your initial client outside of + * a request handler so it persists across requests. If you need to augment a + * client inside a handler, call the `withRule()` function on the base client. + * + * @param options - Arcjet configuration options to apply to all requests. + */ +export default function arcjetNext( + options: ArcjetOptions, +): ArcjetNext>> { + const client = options.client ?? createNextRemoteClient(); + + const aj = arcjet({ ...options, client }); + + return withClient(aj); +} + /** * Protects your Next.js application using Arcjet middleware. * diff --git a/arcjet/index.ts b/arcjet/index.ts index 458fa355c..c21ba2183 100644 --- a/arcjet/index.ts +++ b/arcjet/index.ts @@ -1026,6 +1026,13 @@ export interface Arcjet { */ protect(request: ArcjetRequest): Promise; + /** + * Augments the client with another rule. Useful for varying rules based on + * criteria in your handler—e.g. different rate limit for logged in users. + * + * @param rule The rule to add to this execution. + * @returns An augmented {@link Arcjet} client. + */ withRule( rule: Rule, ): Arcjet>>;