From 2451659cd0a2282775aedcfff6c37e4db35e9736 Mon Sep 17 00:00:00 2001 From: e-moran Date: Thu, 2 Jan 2025 16:46:34 +0000 Subject: [PATCH 1/9] chore!: deprecate `reason` for `ArcjetAllowDecision` --- decorate/index.ts | 4 +++- protocol/client.ts | 3 ++- protocol/convert.ts | 5 ++++- protocol/index.ts | 11 ++++++++--- protocol/test/convert.test.ts | 3 ++- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/decorate/index.ts b/decorate/index.ts index e057fe5c9..0cc87e216 100644 --- a/decorate/index.ts +++ b/decorate/index.ts @@ -208,7 +208,7 @@ export function setRateLimitHeaders( .sort(sortByLowestMax) .map(toPolicyString) .join(", "); - } else { + } else if (typeof decision.reason !== "undefined") { // For cached decisions, we may not have rule results, but we'd still have // the top-level reason. if (isRateLimitReason(decision.reason)) { @@ -229,6 +229,8 @@ export function setRateLimitHeaders( } else { return; } + } else { + return; } if (isHeaderLike(value)) { diff --git a/protocol/client.ts b/protocol/client.ts index e1876f823..797330025 100644 --- a/protocol/client.ts +++ b/protocol/client.ts @@ -8,6 +8,7 @@ import { } from "./convert.js"; import type { ArcjetContext, + ArcjetDecisionWithReason, ArcjetRequestDetails, ArcjetRule, } from "./index.js"; @@ -127,7 +128,7 @@ export function createClient(options: ClientOptions): Client { report( context: ArcjetContext, details: ArcjetRequestDetails, - decision: ArcjetDecision, + decision: ArcjetDecisionWithReason, rules: ArcjetRule[], ): void { const { log } = context; diff --git a/protocol/convert.ts b/protocol/convert.ts index be22430a0..01fc84163 100644 --- a/protocol/convert.ts +++ b/protocol/convert.ts @@ -9,6 +9,7 @@ import type { ArcjetSlidingWindowRateLimitRule, ArcjetShieldRule, ArcjetSensitiveInfoRule, + ArcjetDecisionWithReason, } from "./index.js"; import { ArcjetAllowDecision, @@ -391,7 +392,9 @@ export function ArcjetRuleResultFromProtocol( }); } -export function ArcjetDecisionToProtocol(decision: ArcjetDecision): Decision { +export function ArcjetDecisionToProtocol( + decision: ArcjetDecisionWithReason, +): Decision { return new Decision({ id: decision.id, ttl: decision.ttl, diff --git a/protocol/index.ts b/protocol/index.ts index d256511da..0b576a9b1 100644 --- a/protocol/index.ts +++ b/protocol/index.ts @@ -591,7 +591,7 @@ export abstract class ArcjetDecision { ip: ArcjetIpDetails; abstract conclusion: ArcjetConclusion; - abstract reason: ArcjetReason; + abstract reason: ArcjetReason | undefined; constructor(init: { id?: string; @@ -627,15 +627,20 @@ export abstract class ArcjetDecision { } } +export type ArcjetDecisionWithReason = ArcjetDecision & { + reason: NonNullable; +}; + export class ArcjetAllowDecision extends ArcjetDecision { conclusion = "ALLOW" as const; - reason: ArcjetReason; + /** @deprecated */ + reason: ArcjetReason | undefined; constructor(init: { id?: string; results: ArcjetRuleResult[]; ttl: number; - reason: ArcjetReason; + reason?: ArcjetReason; ip?: ArcjetIpDetails; }) { super(init); diff --git a/protocol/test/convert.test.ts b/protocol/test/convert.test.ts index b2ab97b92..a0ad21840 100644 --- a/protocol/test/convert.test.ts +++ b/protocol/test/convert.test.ts @@ -36,6 +36,7 @@ import type { ArcjetFixedWindowRateLimitRule, ArcjetSlidingWindowRateLimitRule, ArcjetShieldRule, + ArcjetDecisionWithReason, } from "../index.js"; import { ArcjetAllowDecision, @@ -519,7 +520,7 @@ describe("convert", () => { results: [], reason: new ArcjetReason(), ip: new ArcjetIpDetails(), - }), + }) as ArcjetDecisionWithReason, ), ).toEqual( new Decision({ From 8b78916c967b1ae8e0d69a0984f9a060b40ff502 Mon Sep 17 00:00:00 2001 From: e-moran Date: Thu, 2 Jan 2025 17:48:24 +0000 Subject: [PATCH 2/9] fix: examples --- .../app/api/arcjet/route.ts | 9 +++++---- .../app/api/arcjet/route.ts | 16 ++++++++++------ examples/nextjs-openai/app/api/chat/route.ts | 9 +++++---- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/examples/nextjs-bot-categories/app/api/arcjet/route.ts b/examples/nextjs-bot-categories/app/api/arcjet/route.ts index 3ac08e68f..9fcbaf3fc 100644 --- a/examples/nextjs-bot-categories/app/api/arcjet/route.ts +++ b/examples/nextjs-bot-categories/app/api/arcjet/route.ts @@ -34,14 +34,15 @@ export async function GET(req: Request) { } const headers = new Headers(); - if (decision.reason.isBot()) { + const botReason = decision.results.map((rule) => rule.reason).find((reason) => reason.isBot()); + if (typeof botReason !== "undefined") { // WARNING: This is illustrative! Don't share this metadata with users; // otherwise they may use it to subvert bot detection! - headers.set("X-Arcjet-Bot-Allowed", decision.reason.allowed.join(", ")) - headers.set("X-Arcjet-Bot-Denied", decision.reason.denied.join(", ")) + headers.set("X-Arcjet-Bot-Allowed", botReason.allowed.join(", ")) + headers.set("X-Arcjet-Bot-Denied", botReason.denied.join(", ")) // We need to check that the bot is who they say they are. - if (decision.reason.isSpoofed()) { + if (botReason.isSpoofed()) { return NextResponse.json( { error: "You are pretending to be a good bot!" }, { status: 403, headers }, diff --git a/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts b/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts index 873c07200..31b47f1a0 100644 --- a/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts +++ b/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts @@ -69,13 +69,17 @@ export async function GET(req: NextRequest) { ); } } - + let reset: Date | undefined; let remaining: number | undefined; - if (decision.reason.isRateLimit()) { - reset = decision.reason.resetTime; - remaining = decision.reason.remaining; - } + const rateLimitReason = decision.results.map((rule) => rule.reason).find((reason) => reason.isRateLimit()); + if (typeof rateLimitReason !== "undefined") { + if (rateLimitReason.isRateLimit()) { + reset = rateLimitReason.resetTime; + remaining = rateLimitReason.remaining; + } - return NextResponse.json({ message: "Hello World", reset, remaining });} \ No newline at end of file + return NextResponse.json({ message: "Hello World", reset, remaining }); + } +} diff --git a/examples/nextjs-openai/app/api/chat/route.ts b/examples/nextjs-openai/app/api/chat/route.ts index 1ca8d1a9a..8344d89b9 100644 --- a/examples/nextjs-openai/app/api/chat/route.ts +++ b/examples/nextjs-openai/app/api/chat/route.ts @@ -48,7 +48,7 @@ export const runtime = "edge"; export async function POST(req: Request) { const { messages } = await req.json(); - // Estimate the number of tokens required to process the request + // Estimate the number of tokens required to process the request const estimate = promptTokensEstimate({ messages, }); @@ -59,8 +59,9 @@ export async function POST(req: Request) { const decision = await aj.protect(req, { requested: estimate }); console.log("Arcjet decision", decision.conclusion); - if (decision.reason.isRateLimit()) { - console.log("Requests remaining", decision.reason.remaining); + const rateLimitReason = decision.results.map((rule) => rule.reason).find((reason) => reason.isRateLimit()); + if (typeof rateLimitReason !== "undefined") { + console.log("Requests remaining", rateLimitReason.remaining); } // If the request is denied, return a 429 @@ -83,4 +84,4 @@ export async function POST(req: Request) { }); return result.toDataStreamResponse(); -} \ No newline at end of file +} From 3173d91fa61dc993f2f1b4d359cb7bccdf73d301 Mon Sep 17 00:00:00 2001 From: e-moran Date: Wed, 8 Jan 2025 17:37:15 +0000 Subject: [PATCH 3/9] update examples --- decorate/index.ts | 8 +- .../app/api/arcjet/route.ts | 48 ++++++++--- .../app/api/arcjet/route.ts | 79 ++++++++++++++++-- .../nextjs-clerk-rate-limit/package-lock.json | 22 +++++ examples/nextjs-clerk-rate-limit/package.json | 3 +- examples/nextjs-openai/app/api/chat/route.ts | 81 ++++++++++++++++++- examples/nextjs-openai/package-lock.json | 22 +++++ examples/nextjs-openai/package.json | 3 +- protocol/index.ts | 4 + protocol/test/convert.test.ts | 35 ++++---- 10 files changed, 257 insertions(+), 48 deletions(-) diff --git a/decorate/index.ts b/decorate/index.ts index 0cc87e216..2efd1d31c 100644 --- a/decorate/index.ts +++ b/decorate/index.ts @@ -122,9 +122,9 @@ function extractReason(result: ArcjetRuleResult): ArcjetReason { } function isRateLimitReason( - reason: ArcjetReason, + reason?: ArcjetReason, ): reason is ArcjetRateLimitReason { - return reason.isRateLimit(); + return typeof reason !== "undefined" && reason.isRateLimit(); } function nearestLimit( @@ -208,7 +208,7 @@ export function setRateLimitHeaders( .sort(sortByLowestMax) .map(toPolicyString) .join(", "); - } else if (typeof decision.reason !== "undefined") { + } else { // For cached decisions, we may not have rule results, but we'd still have // the top-level reason. if (isRateLimitReason(decision.reason)) { @@ -229,8 +229,6 @@ export function setRateLimitHeaders( } else { return; } - } else { - return; } if (isHeaderLike(value)) { diff --git a/examples/nextjs-bot-categories/app/api/arcjet/route.ts b/examples/nextjs-bot-categories/app/api/arcjet/route.ts index 9fcbaf3fc..f05043b4b 100644 --- a/examples/nextjs-bot-categories/app/api/arcjet/route.ts +++ b/examples/nextjs-bot-categories/app/api/arcjet/route.ts @@ -33,21 +33,43 @@ export async function GET(req: Request) { ) } - const headers = new Headers(); - const botReason = decision.results.map((rule) => rule.reason).find((reason) => reason.isBot()); - if (typeof botReason !== "undefined") { - // WARNING: This is illustrative! Don't share this metadata with users; - // otherwise they may use it to subvert bot detection! - headers.set("X-Arcjet-Bot-Allowed", botReason.allowed.join(", ")) - headers.set("X-Arcjet-Bot-Denied", botReason.denied.join(", ")) + const allowedBots = decision.results.reduce((bots, rule) => { + if (rule.reason.isBot()) { + return [...bots, ...rule.reason.allowed] + } else { + return bots; + } + }, []); + + const deniedBots = decision.results.reduce((bots, rule) => { + if (rule.reason.isBot()) { + return [...bots, ...rule.reason.denied] + } else { + return bots; + } + }, []); - // We need to check that the bot is who they say they are. - if (botReason.isSpoofed()) { - return NextResponse.json( - { error: "You are pretending to be a good bot!" }, - { status: 403, headers }, - ); + const isSpoofed = decision.results.some((rule) => { + if (rule.reason.isBot()) { + return rule.reason.isSpoofed(); + } else { + return false; } + }); + + // WARNING: This is illustrative! Don't share this metadata with users; + // otherwise they may use it to subvert bot detection! + const headers = new Headers({ + "X-Arcjet-Bot-Allowed": allowedBots.join(", "), + "X-Arcjet-Bot-Denied": deniedBots.join(", "), + }); + + // We need to check that the bot is who they say they are. + if (isSpoofed) { + return NextResponse.json( + { error: "You are pretending to be a good bot!" }, + { status: 403, headers }, + ); } if (decision.isDenied()) { diff --git a/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts b/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts index 31b47f1a0..de79fed75 100644 --- a/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts +++ b/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts @@ -1,4 +1,5 @@ -import arcjet, { ArcjetDecision, tokenBucket, shield } from "@arcjet/next"; +import arcjet, { ArcjetDecision, tokenBucket, shield, ArcjetRateLimitReason, ArcjetReason, ArcjetRuleResult } from "@arcjet/next"; +import format from "@arcjet/sprintf"; import { NextRequest, NextResponse } from "next/server"; import { currentUser } from "@clerk/nextjs/server"; import ip from "@arcjet/ip"; @@ -16,6 +17,47 @@ const aj = arcjet({ ], }); +function extractReason(result: ArcjetRuleResult): ArcjetReason { + return result.reason; +} + +function isRateLimitReason( + reason?: ArcjetReason, +): reason is ArcjetRateLimitReason { + return typeof reason !== "undefined" && reason.isRateLimit(); +} + +function nearestLimit( + current: ArcjetRateLimitReason, + next: ArcjetRateLimitReason, +) { + if (current.remaining < next.remaining) { + return current; + } + + if (current.remaining > next.remaining) { + return next; + } + + // Reaching here means `remaining` is equal so prioritize closest reset + if (current.reset < next.reset) { + return current; + } + + if (current.reset > next.reset) { + return next; + } + + // Reaching here means that `remaining` and `reset` are equal, so prioritize + // the smallest `max` + if (current.max < next.max) { + return current; + } + + // All else equal, just return the next item in the list + return next; +} + export async function GET(req: NextRequest) { // Get the current user from Clerk // See https://clerk.com/docs/references/nextjs/current-user @@ -73,13 +115,36 @@ export async function GET(req: NextRequest) { let reset: Date | undefined; let remaining: number | undefined; - const rateLimitReason = decision.results.map((rule) => rule.reason).find((reason) => reason.isRateLimit()); - if (typeof rateLimitReason !== "undefined") { - if (rateLimitReason.isRateLimit()) { - reset = rateLimitReason.resetTime; - remaining = rateLimitReason.remaining; + const rateLimitReasons = decision.results + .map(extractReason) + .filter(isRateLimitReason); + + if (rateLimitReasons.length > 0) { + const policies = new Map(); + for (const reason of rateLimitReasons) { + if (policies.has(reason.max)) { + console.error( + "Invalid rate limit policy—two policies should not share the same limit", + ); + } + + if ( + typeof reason.max !== "number" || + typeof reason.window !== "number" || + typeof reason.remaining !== "number" || + typeof reason.reset !== "number" + ) { + console.error(format("Invalid rate limit encountered: %o", reason)); + } + + policies.set(reason.max, reason.window); } - return NextResponse.json({ message: "Hello World", reset, remaining }); + const rl = rateLimitReasons.reduce(nearestLimit); + + reset = rl.resetTime; + remaining = rl.remaining; } + + return NextResponse.json({ message: "Hello World", reset, remaining }); } diff --git a/examples/nextjs-clerk-rate-limit/package-lock.json b/examples/nextjs-clerk-rate-limit/package-lock.json index 74ed38ea9..71bbc34de 100644 --- a/examples/nextjs-clerk-rate-limit/package-lock.json +++ b/examples/nextjs-clerk-rate-limit/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@arcjet/next": "file:../../arcjet-next", + "@arcjet/sprintf": "file:../../sprintf", "@clerk/nextjs": "^6.9.1", "next": "15.1.0", "react": "^19", @@ -74,6 +75,23 @@ "node": ">=18" } }, + "../../sprintf": { + "name": "@arcjet/sprintf", + "version": "1.0.0-alpha.34", + "license": "Apache-2.0", + "devDependencies": { + "@arcjet/eslint-config": "1.0.0-alpha.34", + "@arcjet/rollup-config": "1.0.0-alpha.34", + "@arcjet/tsconfig": "1.0.0-alpha.34", + "@rollup/wasm-node": "4.28.1", + "@types/node": "18.18.0", + "expect": "29.7.0", + "typescript": "5.7.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -90,6 +108,10 @@ "resolved": "../../arcjet-next", "link": true }, + "node_modules/@arcjet/sprintf": { + "resolved": "../../sprintf", + "link": true + }, "node_modules/@clerk/backend": { "version": "1.21.1", "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-1.21.1.tgz", diff --git a/examples/nextjs-clerk-rate-limit/package.json b/examples/nextjs-clerk-rate-limit/package.json index f85a2337a..0b809adff 100644 --- a/examples/nextjs-clerk-rate-limit/package.json +++ b/examples/nextjs-clerk-rate-limit/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@arcjet/next": "file:../../arcjet-next", + "@arcjet/sprintf": "file:../../sprintf", "@clerk/nextjs": "^6.9.1", "next": "15.1.0", "react": "^19", @@ -26,4 +27,4 @@ "tailwindcss": "^3.4.16", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/examples/nextjs-openai/app/api/chat/route.ts b/examples/nextjs-openai/app/api/chat/route.ts index 8344d89b9..c69b6ebeb 100644 --- a/examples/nextjs-openai/app/api/chat/route.ts +++ b/examples/nextjs-openai/app/api/chat/route.ts @@ -15,7 +15,8 @@ adding custom characteristics so you could use a user ID to track authenticated users instead. See the `chat_userid` example for an example of this. */ -import arcjet, { shield, tokenBucket } from "@arcjet/next"; +import arcjet, { ArcjetRateLimitReason, ArcjetReason, ArcjetRuleResult, shield, tokenBucket } from "@arcjet/next"; +import format from "@arcjet/sprintf"; import { openai } from '@ai-sdk/openai'; import { streamText } from 'ai'; import { promptTokensEstimate } from "openai-chat-tokens"; @@ -39,6 +40,47 @@ const aj = arcjet({ ], }); +function extractReason(result: ArcjetRuleResult): ArcjetReason { + return result.reason; +} + +function isRateLimitReason( + reason?: ArcjetReason, +): reason is ArcjetRateLimitReason { + return typeof reason !== "undefined" && reason.isRateLimit(); +} + +function nearestLimit( + current: ArcjetRateLimitReason, + next: ArcjetRateLimitReason, +) { + if (current.remaining < next.remaining) { + return current; + } + + if (current.remaining > next.remaining) { + return next; + } + + // Reaching here means `remaining` is equal so prioritize closest reset + if (current.reset < next.reset) { + return current; + } + + if (current.reset > next.reset) { + return next; + } + + // Reaching here means that `remaining` and `reset` are equal, so prioritize + // the smallest `max` + if (current.max < next.max) { + return current; + } + + // All else equal, just return the next item in the list + return next; +} + // Allow streaming responses up to 30 seconds export const maxDuration = 30; @@ -59,9 +101,40 @@ export async function POST(req: Request) { const decision = await aj.protect(req, { requested: estimate }); console.log("Arcjet decision", decision.conclusion); - const rateLimitReason = decision.results.map((rule) => rule.reason).find((reason) => reason.isRateLimit()); - if (typeof rateLimitReason !== "undefined") { - console.log("Requests remaining", rateLimitReason.remaining); + const rateLimitReasons = decision.results + .map(extractReason) + .filter(isRateLimitReason); + + let remaining: number | undefined; + + if (rateLimitReasons.length > 0) { + const policies = new Map(); + for (const reason of rateLimitReasons) { + if (policies.has(reason.max)) { + console.error( + "Invalid rate limit policy—two policies should not share the same limit", + ); + } + + if ( + typeof reason.max !== "number" || + typeof reason.window !== "number" || + typeof reason.remaining !== "number" || + typeof reason.reset !== "number" + ) { + console.error(format("Invalid rate limit encountered: %o", reason)); + } + + policies.set(reason.max, reason.window); + } + + const rl = rateLimitReasons.reduce(nearestLimit); + + remaining = rl.remaining; + } + + if (typeof remaining !== "undefined") { + console.log("Requests remaining", remaining); } // If the request is denied, return a 429 diff --git a/examples/nextjs-openai/package-lock.json b/examples/nextjs-openai/package-lock.json index ccc67f45d..ceb5623cd 100644 --- a/examples/nextjs-openai/package-lock.json +++ b/examples/nextjs-openai/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ai-sdk/openai": "^1.0.8", "@arcjet/next": "file:../../arcjet-next", + "@arcjet/sprintf": "file:../../sprintf", "ai": "^4.0.16", "next": "15.1.0", "openai": "^4.76.2", @@ -58,6 +59,23 @@ "next": ">=13" } }, + "../../sprintf": { + "name": "@arcjet/sprintf", + "version": "1.0.0-alpha.34", + "license": "Apache-2.0", + "devDependencies": { + "@arcjet/eslint-config": "1.0.0-alpha.34", + "@arcjet/rollup-config": "1.0.0-alpha.34", + "@arcjet/tsconfig": "1.0.0-alpha.34", + "@rollup/wasm-node": "4.28.1", + "@types/node": "18.18.0", + "expect": "29.7.0", + "typescript": "5.7.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -190,6 +208,10 @@ "resolved": "../../arcjet-next", "link": true }, + "node_modules/@arcjet/sprintf": { + "resolved": "../../sprintf", + "link": true + }, "node_modules/@emnapi/runtime": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", diff --git a/examples/nextjs-openai/package.json b/examples/nextjs-openai/package.json index ad8b4812f..9529ccd59 100644 --- a/examples/nextjs-openai/package.json +++ b/examples/nextjs-openai/package.json @@ -11,6 +11,7 @@ "dependencies": { "@ai-sdk/openai": "^1.0.8", "@arcjet/next": "file:../../arcjet-next", + "@arcjet/sprintf": "file:../../sprintf", "ai": "^4.0.16", "next": "15.1.0", "openai": "^4.76.2", @@ -29,4 +30,4 @@ "tailwindcss": "^3.4.16", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/protocol/index.ts b/protocol/index.ts index 0b576a9b1..c7b5ca56d 100644 --- a/protocol/index.ts +++ b/protocol/index.ts @@ -647,6 +647,10 @@ export class ArcjetAllowDecision extends ArcjetDecision { this.reason = init.reason; } + + hasReason(): this is ArcjetAllowDecision & { reason: ArcjetReason } { + return this.reason !== undefined; + } } export class ArcjetDenyDecision extends ArcjetDecision { diff --git a/protocol/test/convert.test.ts b/protocol/test/convert.test.ts index a0ad21840..cb88ee199 100644 --- a/protocol/test/convert.test.ts +++ b/protocol/test/convert.test.ts @@ -512,24 +512,25 @@ describe("convert", () => { }); test("ArcjetDecisionToProtocol", () => { - expect( - ArcjetDecisionToProtocol( - new ArcjetAllowDecision({ + const decision = new ArcjetAllowDecision({ + id: "abc123", + ttl: 0, + results: [], + reason: new ArcjetReason(), + ip: new ArcjetIpDetails(), + }); + if (decision.hasReason()) { + expect(ArcjetDecisionToProtocol(decision)).toEqual( + new Decision({ id: "abc123", - ttl: 0, - results: [], - reason: new ArcjetReason(), - ip: new ArcjetIpDetails(), - }) as ArcjetDecisionWithReason, - ), - ).toEqual( - new Decision({ - id: "abc123", - conclusion: Conclusion.ALLOW, - ruleResults: [], - reason: new Reason(), - }), - ); + conclusion: Conclusion.ALLOW, + ruleResults: [], + reason: new Reason(), + }), + ); + } else { + throw new Error("decision created with reason have a reason"); + } }); test("ArcjetDecisionFromProtocol", () => { From e3fc64159c8b6fa3ca90905b873d200026fa2e81 Mon Sep 17 00:00:00 2001 From: e-moran Date: Wed, 8 Jan 2025 17:46:46 +0000 Subject: [PATCH 4/9] fix chatuserid --- .../app/api/chat_userid/route.ts | 84 +++++++++++++++++-- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/examples/nextjs-openai/app/api/chat_userid/route.ts b/examples/nextjs-openai/app/api/chat_userid/route.ts index f8cc1c955..bfac3015a 100644 --- a/examples/nextjs-openai/app/api/chat_userid/route.ts +++ b/examples/nextjs-openai/app/api/chat_userid/route.ts @@ -16,7 +16,8 @@ rate limit rule. The value is then passed as a string, number or boolean when calling the protect method. You can use any string value for the key. */ -import arcjet, { shield, tokenBucket } from "@arcjet/next"; +import arcjet, { ArcjetRateLimitReason, ArcjetReason, ArcjetRuleResult, shield, tokenBucket } from "@arcjet/next"; +import format from "@arcjet/sprintf"; import { openai } from '@ai-sdk/openai'; import { streamText } from 'ai'; import { promptTokensEstimate } from "openai-chat-tokens"; @@ -40,6 +41,47 @@ const aj = arcjet({ ], }); +function extractReason(result: ArcjetRuleResult): ArcjetReason { + return result.reason; +} + +function isRateLimitReason( + reason?: ArcjetReason, +): reason is ArcjetRateLimitReason { + return typeof reason !== "undefined" && reason.isRateLimit(); +} + +function nearestLimit( + current: ArcjetRateLimitReason, + next: ArcjetRateLimitReason, +) { + if (current.remaining < next.remaining) { + return current; + } + + if (current.remaining > next.remaining) { + return next; + } + + // Reaching here means `remaining` is equal so prioritize closest reset + if (current.reset < next.reset) { + return current; + } + + if (current.reset > next.reset) { + return next; + } + + // Reaching here means that `remaining` and `reset` are equal, so prioritize + // the smallest `max` + if (current.max < next.max) { + return current; + } + + // All else equal, just return the next item in the list + return next; +} + // Allow streaming responses up to 30 seconds export const maxDuration = 30; @@ -53,7 +95,7 @@ export async function POST(req: Request) { const { messages } = await req.json(); - // Estimate the number of tokens required to process the request + // Estimate the number of tokens required to process the request const estimate = promptTokensEstimate({ messages, }); @@ -64,8 +106,40 @@ export async function POST(req: Request) { const decision = await aj.protect(req, { requested: estimate, userId }); console.log("Arcjet decision", decision.conclusion); - if (decision.reason.isRateLimit()) { - console.log("Requests remaining", decision.reason.remaining); + const rateLimitReasons = decision.results + .map(extractReason) + .filter(isRateLimitReason); + + let remaining: number | undefined; + + if (rateLimitReasons.length > 0) { + const policies = new Map(); + for (const reason of rateLimitReasons) { + if (policies.has(reason.max)) { + console.error( + "Invalid rate limit policy—two policies should not share the same limit", + ); + } + + if ( + typeof reason.max !== "number" || + typeof reason.window !== "number" || + typeof reason.remaining !== "number" || + typeof reason.reset !== "number" + ) { + console.error(format("Invalid rate limit encountered: %o", reason)); + } + + policies.set(reason.max, reason.window); + } + + const rl = rateLimitReasons.reduce(nearestLimit); + + remaining = rl.remaining; + } + + if (typeof remaining !== "undefined") { + console.log("Requests remaining", remaining); } // If the request is denied, return a 429 @@ -88,4 +162,4 @@ export async function POST(req: Request) { }); return result.toDataStreamResponse(); -} \ No newline at end of file +} From 5b25ae973719350f4de30ff3de788d65ce8fa643 Mon Sep 17 00:00:00 2001 From: e-moran Date: Wed, 8 Jan 2025 17:51:08 +0000 Subject: [PATCH 5/9] remove arrow functions --- .../app/api/arcjet/route.ts | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/examples/nextjs-bot-categories/app/api/arcjet/route.ts b/examples/nextjs-bot-categories/app/api/arcjet/route.ts index f05043b4b..ea98b5a24 100644 --- a/examples/nextjs-bot-categories/app/api/arcjet/route.ts +++ b/examples/nextjs-bot-categories/app/api/arcjet/route.ts @@ -1,4 +1,4 @@ -import arcjet, { botCategories, detectBot } from "@arcjet/next"; +import arcjet, { ArcjetRuleResult, botCategories, detectBot } from "@arcjet/next"; import { NextResponse } from "next/server"; const aj = arcjet({ @@ -23,6 +23,26 @@ const aj = arcjet({ ], }); +function reduceAllowedBots(bots: string[], rule: ArcjetRuleResult) { + if (rule.reason.isBot()) { + return [...bots, ...rule.reason.allowed] + } else { + return bots; + } +} + +function reduceDeniedBots(bots: string[], rule: ArcjetRuleResult) { + if (rule.reason.isBot()) { + return [...bots, ...rule.reason.denied] + } else { + return bots; + } +} + +function checkSpoofed(rule: ArcjetRuleResult) { + return rule.reason.isBot() && rule.reason.isSpoofed() +} + export async function GET(req: Request) { const decision = await aj.protect(req); @@ -33,29 +53,11 @@ export async function GET(req: Request) { ) } - const allowedBots = decision.results.reduce((bots, rule) => { - if (rule.reason.isBot()) { - return [...bots, ...rule.reason.allowed] - } else { - return bots; - } - }, []); + const allowedBots = decision.results.reduce(reduceAllowedBots, []); - const deniedBots = decision.results.reduce((bots, rule) => { - if (rule.reason.isBot()) { - return [...bots, ...rule.reason.denied] - } else { - return bots; - } - }, []); + const deniedBots = decision.results.reduce(reduceDeniedBots, []); - const isSpoofed = decision.results.some((rule) => { - if (rule.reason.isBot()) { - return rule.reason.isSpoofed(); - } else { - return false; - } - }); + const isSpoofed = decision.results.some(checkSpoofed); // WARNING: This is illustrative! Don't share this metadata with users; // otherwise they may use it to subvert bot detection! @@ -63,6 +65,8 @@ export async function GET(req: Request) { "X-Arcjet-Bot-Allowed": allowedBots.join(", "), "X-Arcjet-Bot-Denied": deniedBots.join(", "), }); + headers.set("X-Arcjet-Bot-Allowed", allowedBots.join(", ")) + headers.set("X-Arcjet-Bot-Denied", deniedBots.join(", ")) // We need to check that the bot is who they say they are. if (isSpoofed) { From 312655b32e13f1acfd06ecb3df2ac135133e06b8 Mon Sep 17 00:00:00 2001 From: e-moran Date: Fri, 10 Jan 2025 14:33:45 +0000 Subject: [PATCH 6/9] pr comments --- .../app/api/arcjet/route.ts | 60 ++++++------------- examples/nextjs-openai/app/api/chat/route.ts | 59 +++++------------- .../app/api/chat_userid/route.ts | 59 +++++------------- protocol/client.ts | 3 +- protocol/convert.ts | 7 +-- protocol/index.ts | 9 +-- 6 files changed, 57 insertions(+), 140 deletions(-) diff --git a/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts b/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts index de79fed75..6b94f5dc2 100644 --- a/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts +++ b/examples/nextjs-clerk-rate-limit/app/api/arcjet/route.ts @@ -1,4 +1,4 @@ -import arcjet, { ArcjetDecision, tokenBucket, shield, ArcjetRateLimitReason, ArcjetReason, ArcjetRuleResult } from "@arcjet/next"; +import arcjet, { ArcjetDecision, tokenBucket, shield, ArcjetRateLimitReason, ArcjetReason, ArcjetRuleResult, Arcjet } from "@arcjet/next"; import format from "@arcjet/sprintf"; import { NextRequest, NextResponse } from "next/server"; import { currentUser } from "@clerk/nextjs/server"; @@ -17,16 +17,6 @@ const aj = arcjet({ ], }); -function extractReason(result: ArcjetRuleResult): ArcjetReason { - return result.reason; -} - -function isRateLimitReason( - reason?: ArcjetReason, -): reason is ArcjetRateLimitReason { - return typeof reason !== "undefined" && reason.isRateLimit(); -} - function nearestLimit( current: ArcjetRateLimitReason, next: ArcjetRateLimitReason, @@ -58,6 +48,18 @@ function nearestLimit( return next; } +function reduceNearestLimit(currentNearest: ArcjetRateLimitReason | undefined, rule: ArcjetRuleResult) { + if (rule.reason.isRateLimit()) { + if (typeof currentNearest !== "undefined") { + return nearestLimit(currentNearest, rule.reason); + } else { + return rule.reason; + } + } else { + return currentNearest; + } +} + export async function GET(req: NextRequest) { // Get the current user from Clerk // See https://clerk.com/docs/references/nextjs/current-user @@ -112,38 +114,14 @@ export async function GET(req: NextRequest) { } } + // We need to find the nearest rate limit, because multiple rate limit rules could be defined + const nearestRateLimit = decision.results.reduce(reduceNearestLimit, undefined) + let reset: Date | undefined; let remaining: number | undefined; - - const rateLimitReasons = decision.results - .map(extractReason) - .filter(isRateLimitReason); - - if (rateLimitReasons.length > 0) { - const policies = new Map(); - for (const reason of rateLimitReasons) { - if (policies.has(reason.max)) { - console.error( - "Invalid rate limit policy—two policies should not share the same limit", - ); - } - - if ( - typeof reason.max !== "number" || - typeof reason.window !== "number" || - typeof reason.remaining !== "number" || - typeof reason.reset !== "number" - ) { - console.error(format("Invalid rate limit encountered: %o", reason)); - } - - policies.set(reason.max, reason.window); - } - - const rl = rateLimitReasons.reduce(nearestLimit); - - reset = rl.resetTime; - remaining = rl.remaining; + if (typeof nearestRateLimit !== "undefined") { + reset = nearestRateLimit.resetTime; + remaining = nearestRateLimit.remaining; } return NextResponse.json({ message: "Hello World", reset, remaining }); diff --git a/examples/nextjs-openai/app/api/chat/route.ts b/examples/nextjs-openai/app/api/chat/route.ts index c69b6ebeb..4bc22a9a8 100644 --- a/examples/nextjs-openai/app/api/chat/route.ts +++ b/examples/nextjs-openai/app/api/chat/route.ts @@ -40,16 +40,6 @@ const aj = arcjet({ ], }); -function extractReason(result: ArcjetRuleResult): ArcjetReason { - return result.reason; -} - -function isRateLimitReason( - reason?: ArcjetReason, -): reason is ArcjetRateLimitReason { - return typeof reason !== "undefined" && reason.isRateLimit(); -} - function nearestLimit( current: ArcjetRateLimitReason, next: ArcjetRateLimitReason, @@ -81,6 +71,18 @@ function nearestLimit( return next; } +function reduceNearestLimit(currentNearest: ArcjetRateLimitReason | undefined, rule: ArcjetRuleResult) { + if (rule.reason.isRateLimit()) { + if (typeof currentNearest !== "undefined") { + return nearestLimit(currentNearest, rule.reason); + } else { + return rule.reason; + } + } else { + return currentNearest; + } +} + // Allow streaming responses up to 30 seconds export const maxDuration = 30; @@ -101,40 +103,11 @@ export async function POST(req: Request) { const decision = await aj.protect(req, { requested: estimate }); console.log("Arcjet decision", decision.conclusion); - const rateLimitReasons = decision.results - .map(extractReason) - .filter(isRateLimitReason); - - let remaining: number | undefined; - - if (rateLimitReasons.length > 0) { - const policies = new Map(); - for (const reason of rateLimitReasons) { - if (policies.has(reason.max)) { - console.error( - "Invalid rate limit policy—two policies should not share the same limit", - ); - } - - if ( - typeof reason.max !== "number" || - typeof reason.window !== "number" || - typeof reason.remaining !== "number" || - typeof reason.reset !== "number" - ) { - console.error(format("Invalid rate limit encountered: %o", reason)); - } - - policies.set(reason.max, reason.window); - } - - const rl = rateLimitReasons.reduce(nearestLimit); - - remaining = rl.remaining; - } + // We need to find the nearest rate limit, because multiple rate limit rules could be defined + const nearestRateLimit = decision.results.reduce(reduceNearestLimit, undefined) - if (typeof remaining !== "undefined") { - console.log("Requests remaining", remaining); + if (typeof nearestRateLimit !== "undefined") { + console.log("Requests remaining", nearestRateLimit.remaining); } // If the request is denied, return a 429 diff --git a/examples/nextjs-openai/app/api/chat_userid/route.ts b/examples/nextjs-openai/app/api/chat_userid/route.ts index bfac3015a..489af9b1d 100644 --- a/examples/nextjs-openai/app/api/chat_userid/route.ts +++ b/examples/nextjs-openai/app/api/chat_userid/route.ts @@ -41,16 +41,6 @@ const aj = arcjet({ ], }); -function extractReason(result: ArcjetRuleResult): ArcjetReason { - return result.reason; -} - -function isRateLimitReason( - reason?: ArcjetReason, -): reason is ArcjetRateLimitReason { - return typeof reason !== "undefined" && reason.isRateLimit(); -} - function nearestLimit( current: ArcjetRateLimitReason, next: ArcjetRateLimitReason, @@ -82,6 +72,18 @@ function nearestLimit( return next; } +function reduceNearestLimit(currentNearest: ArcjetRateLimitReason | undefined, rule: ArcjetRuleResult) { + if (rule.reason.isRateLimit()) { + if (typeof currentNearest !== "undefined") { + return nearestLimit(currentNearest, rule.reason); + } else { + return rule.reason; + } + } else { + return currentNearest; + } +} + // Allow streaming responses up to 30 seconds export const maxDuration = 30; @@ -106,40 +108,11 @@ export async function POST(req: Request) { const decision = await aj.protect(req, { requested: estimate, userId }); console.log("Arcjet decision", decision.conclusion); - const rateLimitReasons = decision.results - .map(extractReason) - .filter(isRateLimitReason); - - let remaining: number | undefined; - - if (rateLimitReasons.length > 0) { - const policies = new Map(); - for (const reason of rateLimitReasons) { - if (policies.has(reason.max)) { - console.error( - "Invalid rate limit policy—two policies should not share the same limit", - ); - } - - if ( - typeof reason.max !== "number" || - typeof reason.window !== "number" || - typeof reason.remaining !== "number" || - typeof reason.reset !== "number" - ) { - console.error(format("Invalid rate limit encountered: %o", reason)); - } - - policies.set(reason.max, reason.window); - } - - const rl = rateLimitReasons.reduce(nearestLimit); - - remaining = rl.remaining; - } + // We need to find the nearest rate limit, because multiple rate limit rules could be defined + const nearestRateLimit = decision.results.reduce(reduceNearestLimit, undefined) - if (typeof remaining !== "undefined") { - console.log("Requests remaining", remaining); + if (typeof nearestRateLimit !== "undefined") { + console.log("Requests remaining", nearestRateLimit.remaining); } // If the request is denied, return a 429 diff --git a/protocol/client.ts b/protocol/client.ts index 797330025..e1876f823 100644 --- a/protocol/client.ts +++ b/protocol/client.ts @@ -8,7 +8,6 @@ import { } from "./convert.js"; import type { ArcjetContext, - ArcjetDecisionWithReason, ArcjetRequestDetails, ArcjetRule, } from "./index.js"; @@ -128,7 +127,7 @@ export function createClient(options: ClientOptions): Client { report( context: ArcjetContext, details: ArcjetRequestDetails, - decision: ArcjetDecisionWithReason, + decision: ArcjetDecision, rules: ArcjetRule[], ): void { const { log } = context; diff --git a/protocol/convert.ts b/protocol/convert.ts index 01fc84163..011d8a65d 100644 --- a/protocol/convert.ts +++ b/protocol/convert.ts @@ -9,7 +9,6 @@ import type { ArcjetSlidingWindowRateLimitRule, ArcjetShieldRule, ArcjetSensitiveInfoRule, - ArcjetDecisionWithReason, } from "./index.js"; import { ArcjetAllowDecision, @@ -392,14 +391,12 @@ export function ArcjetRuleResultFromProtocol( }); } -export function ArcjetDecisionToProtocol( - decision: ArcjetDecisionWithReason, -): Decision { +export function ArcjetDecisionToProtocol(decision: ArcjetDecision): Decision { return new Decision({ id: decision.id, ttl: decision.ttl, conclusion: ArcjetConclusionToProtocol(decision.conclusion), - reason: ArcjetReasonToProtocol(decision.reason), + reason: decision.reason && ArcjetReasonToProtocol(decision.reason), ruleResults: decision.results.map(ArcjetRuleResultToProtocol), }); } diff --git a/protocol/index.ts b/protocol/index.ts index c7b5ca56d..3c8a9cb28 100644 --- a/protocol/index.ts +++ b/protocol/index.ts @@ -627,13 +627,9 @@ export abstract class ArcjetDecision { } } -export type ArcjetDecisionWithReason = ArcjetDecision & { - reason: NonNullable; -}; - export class ArcjetAllowDecision extends ArcjetDecision { conclusion = "ALLOW" as const; - /** @deprecated */ + /** @deprecated: use results instead */ reason: ArcjetReason | undefined; constructor(init: { @@ -688,7 +684,8 @@ export class ArcjetChallengeDecision extends ArcjetDecision { export class ArcjetErrorDecision extends ArcjetDecision { conclusion = "ERROR" as const; - reason: ArcjetErrorReason; + /** @deprecated: use results instead */ + reason: ArcjetErrorReason | undefined; constructor(init: { id?: string; From a6dcec479b2b9cdffcffeff25f3beab55ab3e5d4 Mon Sep 17 00:00:00 2001 From: e-moran Date: Fri, 10 Jan 2025 15:22:01 +0000 Subject: [PATCH 7/9] remove udep --- protocol/test/convert.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/protocol/test/convert.test.ts b/protocol/test/convert.test.ts index cb88ee199..95a5fcf9a 100644 --- a/protocol/test/convert.test.ts +++ b/protocol/test/convert.test.ts @@ -30,13 +30,11 @@ import { SDKStack, } from "../proto/decide/v1alpha1/decide_pb.js"; import type { - ArcjetBotRule, ArcjetEmailRule, ArcjetTokenBucketRateLimitRule, ArcjetFixedWindowRateLimitRule, ArcjetSlidingWindowRateLimitRule, ArcjetShieldRule, - ArcjetDecisionWithReason, } from "../index.js"; import { ArcjetAllowDecision, From af75332afaa15b03e64b5f178e9a677a4249ab30 Mon Sep 17 00:00:00 2001 From: e-moran Date: Fri, 10 Jan 2025 15:47:42 +0000 Subject: [PATCH 8/9] fix error handling --- .../nextjs-bot-categories/app/api/arcjet/route.ts | 13 +++++++++++-- examples/nextjs-server-actions/app/actions.ts | 15 ++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/examples/nextjs-bot-categories/app/api/arcjet/route.ts b/examples/nextjs-bot-categories/app/api/arcjet/route.ts index ea98b5a24..ae5fcb719 100644 --- a/examples/nextjs-bot-categories/app/api/arcjet/route.ts +++ b/examples/nextjs-bot-categories/app/api/arcjet/route.ts @@ -43,12 +43,21 @@ function checkSpoofed(rule: ArcjetRuleResult) { return rule.reason.isBot() && rule.reason.isSpoofed() } +function collectErrors(errors: string[], rule: ArcjetRuleResult) { + if (rule.reason.isError()) { + return [...errors, rule.reason.message]; + } else { + return errors; + } +} + export async function GET(req: Request) { const decision = await aj.protect(req); - if (decision.isErrored()) { + const errors = decision.results.reduce(collectErrors, []); + if (errors.length > 0) { return NextResponse.json( - { error: decision.reason.message }, + { errors }, { status: 500, statusText: "Internal Server Error" }, ) } diff --git a/examples/nextjs-server-actions/app/actions.ts b/examples/nextjs-server-actions/app/actions.ts index 0474dd36e..7777396ea 100644 --- a/examples/nextjs-server-actions/app/actions.ts +++ b/examples/nextjs-server-actions/app/actions.ts @@ -1,6 +1,6 @@ "use server" -import arcjet, { request, validateEmail } from "@arcjet/next"; +import arcjet, { ArcjetRuleResult, request, validateEmail } from "@arcjet/next"; const aj = arcjet({ key: process.env.ARCJET_KEY!, @@ -11,6 +11,14 @@ const aj = arcjet({ ] }); +function collectErrors(errors: string[], rule: ArcjetRuleResult) { + if (rule.reason.isError()) { + return [...errors, rule.reason.message]; + } else { + return errors; + } +} + export async function validate(prev: { message: string }, formData: FormData) { const email = formData.get("email"); @@ -28,8 +36,9 @@ export async function validate(prev: { message: string }, formData: FormData) { // If Arcjet encounters an error, you could fail "open" or you could respond // with a "closed"-style message like below - if (decision.isErrored()) { - console.log("Error occurred:", decision.reason.message); + const errors = decision.results.reduce(collectErrors, []); + if (errors.length > 0) { + console.log("Errors occurred:", errors); return { message: "Encountered an error" } From 9fc8d23dceea1b8fce2e1907f2b6f3a3aba56599 Mon Sep 17 00:00:00 2001 From: blaine-arcjet <146491715+blaine-arcjet@users.noreply.github.com> Date: Wed, 15 Jan 2025 10:14:20 -0700 Subject: [PATCH 9/9] WIP cleanup reason deprecation (#2857) --- decorate/index.ts | 31 +++++-- decorate/test/decorate.test.ts | 31 ++++--- protocol/client.ts | 1 - protocol/convert.ts | 47 ++++++++-- protocol/index.ts | 19 ++-- protocol/test/client.test.ts | 22 +---- protocol/test/convert.test.ts | 165 +++++++++++++++++++++++++++++---- 7 files changed, 237 insertions(+), 79 deletions(-) diff --git a/decorate/index.ts b/decorate/index.ts index 2efd1d31c..c6f402158 100644 --- a/decorate/index.ts +++ b/decorate/index.ts @@ -122,9 +122,19 @@ function extractReason(result: ArcjetRuleResult): ArcjetReason { } function isRateLimitReason( - reason?: ArcjetReason, + reason: ArcjetReason, ): reason is ArcjetRateLimitReason { - return typeof reason !== "undefined" && reason.isRateLimit(); + return reason.isRateLimit(); +} + +function getRateLimitReason( + decision: ArcjetDecision, +): ArcjetRateLimitReason | undefined { + if (decision.isDenied() || decision.isChallenged()) { + if (decision.reason.isRateLimit()) { + return decision.reason; + } + } } function nearestLimit( @@ -211,21 +221,22 @@ export function setRateLimitHeaders( } else { // For cached decisions, we may not have rule results, but we'd still have // the top-level reason. - if (isRateLimitReason(decision.reason)) { + const rateLimitReason = getRateLimitReason(decision); + if (rateLimitReason) { if ( - typeof decision.reason.max !== "number" || - typeof decision.reason.window !== "number" || - typeof decision.reason.remaining !== "number" || - typeof decision.reason.reset !== "number" + typeof rateLimitReason.max !== "number" || + typeof rateLimitReason.window !== "number" || + typeof rateLimitReason.remaining !== "number" || + typeof rateLimitReason.reset !== "number" ) { console.error( - format("Invalid rate limit encountered: %o", decision.reason), + format("Invalid rate limit encountered: %o", rateLimitReason), ); return; } - limit = toLimitString(decision.reason); - policy = toPolicyString([decision.reason.max, decision.reason.window]); + limit = toLimitString(rateLimitReason); + policy = toPolicyString([rateLimitReason.max, rateLimitReason.window]); } else { return; } diff --git a/decorate/test/decorate.test.ts b/decorate/test/decorate.test.ts index 4087fba26..898a09c27 100644 --- a/decorate/test/decorate.test.ts +++ b/decorate/test/decorate.test.ts @@ -3,6 +3,7 @@ import { expect } from "expect"; import { setRateLimitHeaders } from "../index"; import { ArcjetAllowDecision, + ArcjetDenyDecision, ArcjetRateLimitReason, ArcjetReason, ArcjetRuleResult, @@ -307,7 +308,7 @@ describe("setRateLimitHeaders", () => { const headers = new Headers(); setRateLimitHeaders( headers, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -332,7 +333,7 @@ describe("setRateLimitHeaders", () => { const headers = new Headers(); setRateLimitHeaders( headers, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -356,7 +357,7 @@ describe("setRateLimitHeaders", () => { const headers = new Headers(); setRateLimitHeaders( headers, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -380,7 +381,7 @@ describe("setRateLimitHeaders", () => { const headers = new Headers(); setRateLimitHeaders( headers, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -404,7 +405,7 @@ describe("setRateLimitHeaders", () => { const headers = new Headers(); setRateLimitHeaders( headers, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -1023,7 +1024,7 @@ describe("setRateLimitHeaders", () => { const resp = new Response(); setRateLimitHeaders( resp, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -1048,7 +1049,7 @@ describe("setRateLimitHeaders", () => { const resp = new Response(); setRateLimitHeaders( resp, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -1072,7 +1073,7 @@ describe("setRateLimitHeaders", () => { const resp = new Response(); setRateLimitHeaders( resp, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -1096,7 +1097,7 @@ describe("setRateLimitHeaders", () => { const resp = new Response(); setRateLimitHeaders( resp, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -1120,7 +1121,7 @@ describe("setRateLimitHeaders", () => { const resp = new Response(); setRateLimitHeaders( resp, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -1834,7 +1835,7 @@ describe("setRateLimitHeaders", () => { const resp = new OutgoingMessage(); setRateLimitHeaders( resp, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -1859,7 +1860,7 @@ describe("setRateLimitHeaders", () => { const resp = new OutgoingMessage(); setRateLimitHeaders( resp, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -1883,7 +1884,7 @@ describe("setRateLimitHeaders", () => { const resp = new OutgoingMessage(); setRateLimitHeaders( resp, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -1907,7 +1908,7 @@ describe("setRateLimitHeaders", () => { const resp = new OutgoingMessage(); setRateLimitHeaders( resp, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ @@ -1931,7 +1932,7 @@ describe("setRateLimitHeaders", () => { const resp = new OutgoingMessage(); setRateLimitHeaders( resp, - new ArcjetAllowDecision({ + new ArcjetDenyDecision({ results: [], ttl: 0, reason: new ArcjetRateLimitReason({ diff --git a/protocol/client.ts b/protocol/client.ts index e1876f823..6e4bf1f92 100644 --- a/protocol/client.ts +++ b/protocol/client.ts @@ -115,7 +115,6 @@ export function createClient(options: ClientOptions): Client { runtime: context.runtime, ttl: decision.ttl, conclusion: decision.conclusion, - reason: decision.reason, ruleResults: decision.results, }, "Decide response", diff --git a/protocol/convert.ts b/protocol/convert.ts index 011d8a65d..f2199c249 100644 --- a/protocol/convert.ts +++ b/protocol/convert.ts @@ -392,13 +392,45 @@ export function ArcjetRuleResultFromProtocol( } export function ArcjetDecisionToProtocol(decision: ArcjetDecision): Decision { - return new Decision({ - id: decision.id, - ttl: decision.ttl, - conclusion: ArcjetConclusionToProtocol(decision.conclusion), - reason: decision.reason && ArcjetReasonToProtocol(decision.reason), - ruleResults: decision.results.map(ArcjetRuleResultToProtocol), - }); + if (decision.isDenied()) { + return new Decision({ + id: decision.id, + ttl: decision.ttl, + conclusion: ArcjetConclusionToProtocol(decision.conclusion), + reason: ArcjetReasonToProtocol(decision.reason), + ruleResults: decision.results.map(ArcjetRuleResultToProtocol), + }); + } + + if (decision.isChallenged()) { + return new Decision({ + id: decision.id, + ttl: decision.ttl, + conclusion: ArcjetConclusionToProtocol(decision.conclusion), + reason: ArcjetReasonToProtocol(decision.reason), + ruleResults: decision.results.map(ArcjetRuleResultToProtocol), + }); + } + + if (decision.isErrored()) { + return new Decision({ + id: decision.id, + ttl: decision.ttl, + conclusion: ArcjetConclusionToProtocol(decision.conclusion), + ruleResults: decision.results.map(ArcjetRuleResultToProtocol), + }); + } + + if (decision.isAllowed()) { + return new Decision({ + id: decision.id, + ttl: decision.ttl, + conclusion: ArcjetConclusionToProtocol(decision.conclusion), + ruleResults: decision.results.map(ArcjetRuleResultToProtocol), + }); + } + + return new Decision(); } export function ArcjetIpDetailsFromProtocol( @@ -667,6 +699,7 @@ export function ArcjetRuleToProtocol( rule: { case: "sensitiveInfo", value: { + mode: ArcjetModeToProtocol(rule.mode), allow: rule.allow, deny: rule.deny, }, diff --git a/protocol/index.ts b/protocol/index.ts index 321138cf9..4a1c62df2 100644 --- a/protocol/index.ts +++ b/protocol/index.ts @@ -575,10 +575,6 @@ export class ArcjetIpDetails { * the decision in the Arcjet dashboard. * @property `conclusion` - Arcjet's conclusion about the request. This will be * one of `"ALLOW"`, `"DENY"`, `"CHALLENGE"`, or `"ERROR"`. - * @property `reason` - A structured data type about the reason for the - * decision. One of: {@link ArcjetRateLimitReason}, {@link ArcjetEdgeRuleReason}, - * {@link ArcjetBotReason}, {@link ArcjetShieldReason}, - * {@link ArcjetEmailReason}, or {@link ArcjetErrorReason}. * @property `ttl` - The duration in milliseconds this decision should be * considered valid, also known as time-to-live. * @property `results` - Each separate {@link ArcjetRuleResult} can be found here @@ -599,7 +595,6 @@ export abstract class ArcjetDecision { ip: ArcjetIpDetails; abstract conclusion: ArcjetConclusion; - abstract reason: ArcjetReason | undefined; constructor(init: { id?: string; @@ -637,7 +632,9 @@ export abstract class ArcjetDecision { export class ArcjetAllowDecision extends ArcjetDecision { conclusion = "ALLOW" as const; - /** @deprecated: use results instead */ + /** + * @deprecated Iterate rule results via `decision.results` instead. + **/ reason: ArcjetReason | undefined; constructor(init: { @@ -651,10 +648,6 @@ export class ArcjetAllowDecision extends ArcjetDecision { this.reason = init.reason; } - - hasReason(): this is ArcjetAllowDecision & { reason: ArcjetReason } { - return this.reason !== undefined; - } } export class ArcjetDenyDecision extends ArcjetDecision { @@ -692,14 +685,16 @@ export class ArcjetChallengeDecision extends ArcjetDecision { export class ArcjetErrorDecision extends ArcjetDecision { conclusion = "ERROR" as const; - /** @deprecated: use results instead */ + /** + * @deprecated Iterate rule results via `decision.results` instead. + * */ reason: ArcjetErrorReason | undefined; constructor(init: { id?: string; results: ArcjetRuleResult[]; ttl: number; - reason: ArcjetErrorReason; + reason?: ArcjetErrorReason; ip?: ArcjetIpDetails; }) { super(init); diff --git a/protocol/test/client.test.ts b/protocol/test/client.test.ts index 603b59b23..90102cdc1 100644 --- a/protocol/test/client.test.ts +++ b/protocol/test/client.test.ts @@ -565,7 +565,8 @@ describe("createClient", () => { const decision = await client.decide(context, details, []); expect(decision.isErrored()).toBe(true); - expect(decision.reason).toMatchObject({ + // Duplicated `isErrored()` narrowing because the assertion library isn't assertion aware + expect(decision.isErrored() && decision.reason).toMatchObject({ message: "Unknown error occurred", }); }); @@ -619,7 +620,8 @@ describe("createClient", () => { const decision = await client.decide(context, details, []); expect(decision.isErrored()).toBe(true); - expect(decision.reason).toMatchObject({ + // Duplicated `isErrored()` narrowing because the assertion library isn't assertion aware + expect(decision.isErrored() && decision.reason).toMatchObject({ message: "Boom!", }); }); @@ -785,7 +787,6 @@ describe("createClient", () => { decision: { id: decision.id, conclusion: Conclusion.ALLOW, - reason: new Reason(), ruleResults: [], }, }), @@ -921,14 +922,6 @@ describe("createClient", () => { decision: { id: decision.id, conclusion: Conclusion.ERROR, - reason: new Reason({ - reason: { - case: "error", - value: { - message: "Failure", - }, - }, - }), ruleResults: [], }, }), @@ -1057,12 +1050,7 @@ describe("createClient", () => { ...details, headers: { "user-agent": "curl/8.1.2" }, }, - decision: { - id: decision.id, - conclusion: Conclusion.UNSPECIFIED, - reason: new Reason(), - ruleResults: [], - }, + decision: {}, }), expect.anything(), ]); diff --git a/protocol/test/convert.test.ts b/protocol/test/convert.test.ts index 95a5fcf9a..1941c9fc4 100644 --- a/protocol/test/convert.test.ts +++ b/protocol/test/convert.test.ts @@ -23,6 +23,7 @@ import { EmailType, Mode, RateLimitAlgorithm, + RateLimitReason, Reason, Rule, RuleResult, @@ -35,6 +36,8 @@ import type { ArcjetFixedWindowRateLimitRule, ArcjetSlidingWindowRateLimitRule, ArcjetShieldRule, + ArcjetSensitiveInfoRule, + ArcjetConclusion, } from "../index.js"; import { ArcjetAllowDecision, @@ -51,6 +54,7 @@ import { ArcjetShieldReason, ArcjetIpDetails, ArcjetSensitiveInfoReason, + ArcjetDecision, } from "../index.js"; import { Timestamp } from "@bufbuild/protobuf"; @@ -113,6 +117,12 @@ describe("convert", () => { expect(ArcjetStackToProtocol("NODEJS")).toEqual(SDKStack.SDK_STACK_NODEJS); expect(ArcjetStackToProtocol("NEXTJS")).toEqual(SDKStack.SDK_STACK_NEXTJS); expect(ArcjetStackToProtocol("BUN")).toEqual(SDKStack.SDK_STACK_BUN); + expect(ArcjetStackToProtocol("SVELTEKIT")).toEqual( + SDKStack.SDK_STACK_SVELTEKIT, + ); + expect(ArcjetStackToProtocol("DENO")).toEqual(SDKStack.SDK_STACK_DENO); + expect(ArcjetStackToProtocol("NESTJS")).toEqual(SDKStack.SDK_STACK_NESTJS); + expect(ArcjetStackToProtocol("REMIX")).toEqual(SDKStack.SDK_STACK_REMIX); expect( ArcjetStackToProtocol( // @ts-expect-error @@ -319,6 +329,17 @@ describe("convert", () => { reason: "NOT_VALID", }); }).toThrow("Invalid Reason"); + // Bot rule V1 is deprecated and produces an Error Reason + expect( + ArcjetReasonFromProtocol( + new Reason({ + reason: { + case: "bot", + value: {}, + }, + }), + ), + ).toBeInstanceOf(ArcjetErrorReason); }); test("ArcjetReasonToProtocol", () => { @@ -510,25 +531,119 @@ describe("convert", () => { }); test("ArcjetDecisionToProtocol", () => { - const decision = new ArcjetAllowDecision({ - id: "abc123", - ttl: 0, - results: [], - reason: new ArcjetReason(), - ip: new ArcjetIpDetails(), - }); - if (decision.hasReason()) { - expect(ArcjetDecisionToProtocol(decision)).toEqual( - new Decision({ + expect( + ArcjetDecisionToProtocol( + new ArcjetAllowDecision({ id: "abc123", - conclusion: Conclusion.ALLOW, - ruleResults: [], - reason: new Reason(), + ttl: 0, + results: [], + ip: new ArcjetIpDetails(), }), - ); - } else { - throw new Error("decision created with reason have a reason"); - } + ), + ).toEqual( + new Decision({ + id: "abc123", + conclusion: Conclusion.ALLOW, + ruleResults: [], + }), + ); + expect( + ArcjetDecisionToProtocol( + new ArcjetErrorDecision({ + id: "abc123", + ttl: 0, + results: [], + ip: new ArcjetIpDetails(), + }), + ), + ).toEqual( + new Decision({ + id: "abc123", + conclusion: Conclusion.ERROR, + ruleResults: [], + }), + ); + expect( + ArcjetDecisionToProtocol( + new ArcjetChallengeDecision({ + id: "abc123", + ttl: 0, + results: [], + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + window: 1, + reset: 1, + }), + ip: new ArcjetIpDetails(), + }), + ), + ).toEqual( + new Decision({ + id: "abc123", + conclusion: Conclusion.CHALLENGE, + ruleResults: [], + reason: new Reason({ + reason: { + case: "rateLimit", + value: new RateLimitReason({ + max: 1, + remaining: 0, + windowInSeconds: 1, + resetInSeconds: 1, + }), + }, + }), + }), + ); + expect( + ArcjetDecisionToProtocol( + new ArcjetDenyDecision({ + id: "abc123", + ttl: 0, + results: [], + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + window: 1, + reset: 1, + }), + ip: new ArcjetIpDetails(), + }), + ), + ).toEqual( + new Decision({ + id: "abc123", + conclusion: Conclusion.DENY, + ruleResults: [], + reason: new Reason({ + reason: { + case: "rateLimit", + value: new RateLimitReason({ + max: 1, + remaining: 0, + windowInSeconds: 1, + resetInSeconds: 1, + }), + }, + }), + }), + ); + + const ArcjetInvalidDecision = class extends ArcjetDecision { + // @ts-expect-error + conclusion: ArcjetConclusion = "INVALID"; + }; + expect( + ArcjetDecisionToProtocol( + new ArcjetInvalidDecision({ + id: "abc123", + ttl: 0, + results: [], + ip: new ArcjetIpDetails(), + }), + ), + ).toEqual(new Decision()); }); test("ArcjetDecisionFromProtocol", () => { @@ -709,5 +824,21 @@ describe("convert", () => { }, }), ); + expect( + ArcjetRuleToProtocol(>{ + type: "SENSITIVE_INFO", + mode: "DRY_RUN", + priority: 1, + }), + ).toEqual( + new Rule({ + rule: { + case: "sensitiveInfo", + value: { + mode: Mode.DRY_RUN, + }, + }, + }), + ); }); });