diff --git a/decorate/index.ts b/decorate/index.ts index e057fe5c9..c6f402158 100644 --- a/decorate/index.ts +++ b/decorate/index.ts @@ -127,6 +127,16 @@ function isRateLimitReason( return reason.isRateLimit(); } +function getRateLimitReason( + decision: ArcjetDecision, +): ArcjetRateLimitReason | undefined { + if (decision.isDenied() || decision.isChallenged()) { + if (decision.reason.isRateLimit()) { + return decision.reason; + } + } +} + function nearestLimit( current: ArcjetRateLimitReason, next: ArcjetRateLimitReason, @@ -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/examples/nextjs-bot-categories/app/api/arcjet/route.ts b/examples/nextjs-bot-categories/app/api/arcjet/route.ts index 3ac08e68f..ae5fcb719 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,30 +23,66 @@ 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() +} + +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" }, ) } - const headers = new Headers(); - if (decision.reason.isBot()) { - // 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(", ")) - - // We need to check that the bot is who they say they are. - if (decision.reason.isSpoofed()) { - return NextResponse.json( - { error: "You are pretending to be a good bot!" }, - { status: 403, headers }, - ); - } + const allowedBots = decision.results.reduce(reduceAllowedBots, []); + + const deniedBots = decision.results.reduce(reduceDeniedBots, []); + + 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! + const headers = new Headers({ + "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) { + 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 873c07200..c3c77d369 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 } from "@arcjet/next"; +import arcjet, { ArcjetDecision, tokenBucket, shield, ArcjetRateLimitReason, ArcjetRuleResult } from "@arcjet/next"; import { NextRequest, NextResponse } from "next/server"; import { currentUser } from "@clerk/nextjs/server"; import ip from "@arcjet/ip"; @@ -16,6 +16,49 @@ const aj = arcjet({ ], }); +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; +} + +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 @@ -69,13 +112,16 @@ 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; - - if (decision.reason.isRateLimit()) { - reset = decision.reason.resetTime; - remaining = decision.reason.remaining; + if (typeof nearestRateLimit !== "undefined") { + reset = nearestRateLimit.resetTime; + remaining = nearestRateLimit.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-clerk-rate-limit/package-lock.json b/examples/nextjs-clerk-rate-limit/package-lock.json index fd48b6340..10f539b06 100644 --- a/examples/nextjs-clerk-rate-limit/package-lock.json +++ b/examples/nextjs-clerk-rate-limit/package-lock.json @@ -74,6 +74,24 @@ "node": ">=18" } }, + "../../sprintf": { + "name": "@arcjet/sprintf", + "version": "1.0.0-alpha.34", + "extraneous": true, + "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.30.1", + "@types/node": "18.18.0", + "expect": "29.7.0", + "typescript": "5.7.3" + }, + "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", diff --git a/examples/nextjs-clerk-rate-limit/package.json b/examples/nextjs-clerk-rate-limit/package.json index 8431b0e90..96f6c1215 100644 --- a/examples/nextjs-clerk-rate-limit/package.json +++ b/examples/nextjs-clerk-rate-limit/package.json @@ -26,4 +26,4 @@ "tailwindcss": "^3.4.17", "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 1ca8d1a9a..28d10d11c 100644 --- a/examples/nextjs-openai/app/api/chat/route.ts +++ b/examples/nextjs-openai/app/api/chat/route.ts @@ -15,7 +15,7 @@ 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, ArcjetRuleResult, shield, tokenBucket } from "@arcjet/next"; import { openai } from '@ai-sdk/openai'; import { streamText } from 'ai'; import { promptTokensEstimate } from "openai-chat-tokens"; @@ -39,6 +39,49 @@ const aj = arcjet({ ], }); +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; +} + +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; @@ -48,7 +91,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 +102,11 @@ 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); + // 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 nearestRateLimit !== "undefined") { + console.log("Requests remaining", nearestRateLimit.remaining); } // If the request is denied, return a 429 @@ -83,4 +129,4 @@ export async function POST(req: Request) { }); return result.toDataStreamResponse(); -} \ No newline at end of file +} diff --git a/examples/nextjs-openai/app/api/chat_userid/route.ts b/examples/nextjs-openai/app/api/chat_userid/route.ts index f8cc1c955..36751772e 100644 --- a/examples/nextjs-openai/app/api/chat_userid/route.ts +++ b/examples/nextjs-openai/app/api/chat_userid/route.ts @@ -16,7 +16,7 @@ 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, ArcjetRuleResult, shield, tokenBucket } from "@arcjet/next"; import { openai } from '@ai-sdk/openai'; import { streamText } from 'ai'; import { promptTokensEstimate } from "openai-chat-tokens"; @@ -40,6 +40,49 @@ const aj = arcjet({ ], }); +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; +} + +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; @@ -53,7 +96,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 +107,11 @@ 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); + // 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 nearestRateLimit !== "undefined") { + console.log("Requests remaining", nearestRateLimit.remaining); } // If the request is denied, return a 429 @@ -88,4 +134,4 @@ export async function POST(req: Request) { }); return result.toDataStreamResponse(); -} \ No newline at end of file +} diff --git a/examples/nextjs-openai/package-lock.json b/examples/nextjs-openai/package-lock.json index 31b413acc..9eefc18f9 100644 --- a/examples/nextjs-openai/package-lock.json +++ b/examples/nextjs-openai/package-lock.json @@ -58,6 +58,24 @@ "next": ">=13" } }, + "../../sprintf": { + "name": "@arcjet/sprintf", + "version": "1.0.0-alpha.34", + "extraneous": true, + "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.30.1", + "@types/node": "18.18.0", + "expect": "29.7.0", + "typescript": "5.7.3" + }, + "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", diff --git a/examples/nextjs-openai/package.json b/examples/nextjs-openai/package.json index 0e753e282..08626c9dc 100644 --- a/examples/nextjs-openai/package.json +++ b/examples/nextjs-openai/package.json @@ -29,4 +29,4 @@ "tailwindcss": "^3.4.17", "typescript": "^5" } -} \ No newline at end of file +} 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" } 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 be22430a0..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: 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 89b46f8da..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; constructor(init: { id?: string; @@ -637,13 +632,16 @@ export abstract class ArcjetDecision { export class ArcjetAllowDecision extends ArcjetDecision { conclusion = "ALLOW" as const; - reason: ArcjetReason; + /** + * @deprecated Iterate rule results via `decision.results` instead. + **/ + reason: ArcjetReason | undefined; constructor(init: { id?: string; results: ArcjetRuleResult[]; ttl: number; - reason: ArcjetReason; + reason?: ArcjetReason; ip?: ArcjetIpDetails; }) { super(init); @@ -687,13 +685,16 @@ export class ArcjetChallengeDecision extends ArcjetDecision { export class ArcjetErrorDecision extends ArcjetDecision { conclusion = "ERROR" as const; - reason: ArcjetErrorReason; + /** + * @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 b2ab97b92..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, @@ -30,12 +31,13 @@ import { SDKStack, } from "../proto/decide/v1alpha1/decide_pb.js"; import type { - ArcjetBotRule, ArcjetEmailRule, ArcjetTokenBucketRateLimitRule, ArcjetFixedWindowRateLimitRule, ArcjetSlidingWindowRateLimitRule, ArcjetShieldRule, + ArcjetSensitiveInfoRule, + ArcjetConclusion, } from "../index.js"; import { ArcjetAllowDecision, @@ -52,6 +54,7 @@ import { ArcjetShieldReason, ArcjetIpDetails, ArcjetSensitiveInfoReason, + ArcjetDecision, } from "../index.js"; import { Timestamp } from "@bufbuild/protobuf"; @@ -114,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 @@ -320,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", () => { @@ -517,7 +537,6 @@ describe("convert", () => { id: "abc123", ttl: 0, results: [], - reason: new ArcjetReason(), ip: new ArcjetIpDetails(), }), ), @@ -526,9 +545,105 @@ describe("convert", () => { id: "abc123", conclusion: Conclusion.ALLOW, ruleResults: [], - reason: new Reason(), }), ); + 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, + }, + }, + }), + ); }); });