Comparing version 1.0.0-alpha.7 to 1.0.0-alpha.8
@@ -9,2 +9,5 @@ import { ArcjetContext, ArcjetBotType, ArcjetEmailType, ArcjetMode, ArcjetStack, ArcjetDecision, ArcjetRule, ArcjetRequestDetails } from "@arcjet/protocol"; | ||
Intersection & Union : never; | ||
type IsNever<T> = [T] extends [never] ? true : false; | ||
type LiteralCheck<T, LiteralType extends null | undefined | string | number | boolean | symbol | bigint> = IsNever<T> extends false ? [T] extends [LiteralType] ? [LiteralType] extends [T] ? false : true : false : false; | ||
type IsStringLiteral<T> = LiteralCheck<T, string>; | ||
export interface RemoteClient { | ||
@@ -46,10 +49,24 @@ decide(context: ArcjetContext, details: Partial<ArcjetRequestDetails>, rules: ArcjetRule[]): Promise<ArcjetDecision>; | ||
} | ||
export type RateLimitOptions = { | ||
type TokenBucketRateLimitOptions<Characteristics extends readonly string[]> = { | ||
mode?: ArcjetMode; | ||
match?: string; | ||
characteristics?: string[]; | ||
window: string; | ||
characteristics?: Characteristics; | ||
refillRate: number; | ||
interval: string | number; | ||
capacity: number; | ||
}; | ||
type FixedWindowRateLimitOptions<Characteristics extends readonly string[]> = { | ||
mode?: ArcjetMode; | ||
match?: string; | ||
characteristics?: Characteristics; | ||
window: string | number; | ||
max: number; | ||
timeout: string; | ||
}; | ||
type SlidingWindowRateLimitOptions<Characteristics extends readonly string[]> = { | ||
mode?: ArcjetMode; | ||
match?: string; | ||
characteristics?: Characteristics; | ||
interval: string | number; | ||
max: number; | ||
}; | ||
/** | ||
@@ -124,8 +141,26 @@ * Bot detection is disabled by default. The `bots` configuration block allows | ||
}; | ||
export type Primitive<Props extends PlainObject = {}> = ArcjetRule<Props>[]; | ||
export type Product<Props extends PlainObject = {}> = ArcjetRule<Props>[]; | ||
type PropsForCharacteristic<T> = IsStringLiteral<T> extends true ? T extends "ip.src" | "http.host" | "http.method" | "http.request.uri.path" | `http.request.headers["${string}"]` | `http.request.cookie["${string}"]` | `http.request.uri.args["${string}"]` ? {} : T extends string ? Record<T, string | number | boolean> : never : {}; | ||
type PropsForRule<R> = R extends ArcjetRule<infer Props> ? Props : {}; | ||
export type ExtraProps<Rules> = Rules extends [] ? {} : Rules extends ArcjetRule[][] ? UnionToIntersection<PropsForRule<Rules[number][number]>> : Rules extends ArcjetRule[] ? UnionToIntersection<PropsForRule<Rules[number]>> : never; | ||
export type ArcjetRequest<Props extends PlainObject> = Simplify<Partial<ArcjetRequestDetails & Props>>; | ||
export type Primitive<Props extends PlainObject = {}> = ArcjetRule<Props>[]; | ||
export type Product<Props extends PlainObject = {}> = ArcjetRule<Props>[]; | ||
export declare function rateLimit(options?: RateLimitOptions, ...additionalOptions: RateLimitOptions[]): Primitive; | ||
/** | ||
* @property {string} ip - The IP address of the client. | ||
* @property {string} method - The HTTP method of the request. | ||
* @property {string} protocol - The protocol of the request. | ||
* @property {string} host - The host of the request. | ||
* @property {string} path - The path of the request. | ||
* @property {Headers} headers - The headers of the request. | ||
* @property {string} cookies - The string representing semicolon-separated Cookies for a request. | ||
* @property {string} query - The `?`-prefixed string representing the Query for a request. Commonly referred to as a "querystring". | ||
* @property {string} email - An email address related to the request. | ||
* @property ...extra - Extra data that might be useful for Arcjet. For example, requested tokens are specified as the `requested` property. | ||
*/ | ||
export type ArcjetRequest<Props extends PlainObject> = Simplify<Partial<ArcjetRequestDetails> & Props>; | ||
export declare function tokenBucket<const Characteristics extends readonly string[] = []>(options?: TokenBucketRateLimitOptions<Characteristics>, ...additionalOptions: TokenBucketRateLimitOptions<Characteristics>[]): Primitive<Simplify<UnionToIntersection<{ | ||
requested: number; | ||
} | PropsForCharacteristic<Characteristics[number]>>>>; | ||
export declare function fixedWindow<const Characteristics extends readonly string[] = []>(options?: FixedWindowRateLimitOptions<Characteristics>, ...additionalOptions: FixedWindowRateLimitOptions<Characteristics>[]): Primitive<Simplify<UnionToIntersection<PropsForCharacteristic<Characteristics[number]>>>>; | ||
export declare function rateLimit<const Characteristics extends readonly string[] = []>(options?: FixedWindowRateLimitOptions<Characteristics>, ...additionalOptions: FixedWindowRateLimitOptions<Characteristics>[]): Primitive<Simplify<UnionToIntersection<PropsForCharacteristic<Characteristics[number]>>>>; | ||
export declare function slidingWindow<const Characteristics extends readonly string[] = []>(options?: SlidingWindowRateLimitOptions<Characteristics>, ...additionalOptions: SlidingWindowRateLimitOptions<Characteristics>[]): Primitive<Simplify<UnionToIntersection<PropsForCharacteristic<Characteristics[number]>>>>; | ||
export declare function validateEmail(options?: EmailOptions, ...additionalOptions: EmailOptions[]): Primitive<{ | ||
@@ -135,10 +170,10 @@ email: string; | ||
export declare function detectBot(options?: BotOptions, ...additionalOptions: BotOptions[]): Primitive; | ||
export type ProtectSignupOptions = { | ||
rateLimit?: RateLimitOptions | RateLimitOptions[]; | ||
export type ProtectSignupOptions<Characteristics extends string[]> = { | ||
rateLimit?: SlidingWindowRateLimitOptions<Characteristics> | SlidingWindowRateLimitOptions<Characteristics>[]; | ||
bots?: BotOptions | BotOptions[]; | ||
email?: EmailOptions | EmailOptions[]; | ||
}; | ||
export declare function protectSignup(options?: ProtectSignupOptions): Product<{ | ||
export declare function protectSignup<const Characteristics extends string[] = []>(options?: ProtectSignupOptions<Characteristics>): Product<Simplify<UnionToIntersection<{ | ||
email: string; | ||
}>; | ||
} | PropsForCharacteristic<Characteristics[number]>>>>; | ||
export interface ArcjetOptions<Rules extends [...(Primitive | Product)[]]> { | ||
@@ -169,11 +204,3 @@ /** | ||
* | ||
* @param {ArcjetRequest} request - The details about the request that Arcjet needs to make a decision. | ||
* @param {string} request.ip - The IP address of the client. | ||
* @param {string} request.method - The HTTP method of the request. | ||
* @param {string} request.protocol - The protocol of the request. | ||
* @param {string} request.host - The host of the request. | ||
* @param {string} request.path - The path of the request. | ||
* @param {Headers} request.headers - The headers of the request. | ||
* @param request.extra - Extra data to send to the Arcjet API. | ||
* | ||
* @param {ArcjetRequest} request - Details about the {@link ArcjetRequest} that Arcjet needs to make a decision. | ||
* @returns An {@link ArcjetDecision} indicating Arcjet's decision about the request. | ||
@@ -180,0 +207,0 @@ */ |
175
index.js
@@ -6,2 +6,3 @@ import { ArcjetErrorDecision, ArcjetErrorReason, ArcjetRuleResult, ArcjetReason, ArcjetDenyDecision, ArcjetEmailReason, ArcjetBotType, ArcjetBotReason } from '@arcjet/protocol'; | ||
import * as analyze from '@arcjet/analyze'; | ||
import * as duration from '@arcjet/duration'; | ||
import { Logger } from '@arcjet/logger'; | ||
@@ -17,2 +18,5 @@ | ||
} | ||
function nowInSeconds() { | ||
return Math.floor(Date.now() / 1000); | ||
} | ||
class Cache { | ||
@@ -36,8 +40,8 @@ expires; | ||
} | ||
set(key, value, ttl) { | ||
this.expires.set(key, Date.now() + ttl); | ||
set(key, value, expiresAt) { | ||
this.expires.set(key, expiresAt); | ||
this.data.set(key, value); | ||
} | ||
ttl(key) { | ||
const now = Date.now(); | ||
const now = nowInSeconds(); | ||
const expiresAt = this.expires.get(key) ?? now; | ||
@@ -60,7 +64,62 @@ return expiresAt - now; | ||
} | ||
const baseUrlAllowed = [ | ||
"https://decide.arcjet.com", | ||
"https://decide.arcjettest.com", | ||
"https://decide.arcjet.orb.local:4082", | ||
]; | ||
function defaultBaseUrl() { | ||
return process.env["ARCJET_BASE_URL"] | ||
? process.env["ARCJET_BASE_URL"] // If ARCJET_BASE_URL is set, use it | ||
: "https://decide.arcjet.com"; // Otherwise use the default | ||
// TODO(#90): Remove this production conditional before 1.0.0 | ||
if (process.env["NODE_ENV"] === "production") { | ||
// Use ARCJET_BASE_URL if it is set and belongs to our allowlist; otherwise | ||
// use the hardcoded default. | ||
if (typeof process.env["ARCJET_BASE_URL"] === "string" && | ||
baseUrlAllowed.includes(process.env["ARCJET_BASE_URL"])) { | ||
return process.env["ARCJET_BASE_URL"]; | ||
} | ||
else { | ||
return "https://decide.arcjet.com"; | ||
} | ||
} | ||
else { | ||
return process.env["ARCJET_BASE_URL"] | ||
? process.env["ARCJET_BASE_URL"] | ||
: "https://decide.arcjet.com"; | ||
} | ||
} | ||
const knownFields = [ | ||
"ip", | ||
"method", | ||
"protocol", | ||
"host", | ||
"path", | ||
"headers", | ||
"body", | ||
"email", | ||
"cookies", | ||
"query", | ||
]; | ||
function isUnknownRequestProperty(key) { | ||
return !knownFields.includes(key); | ||
} | ||
function toString(value) { | ||
if (typeof value === "string") { | ||
return value; | ||
} | ||
if (typeof value === "number") { | ||
return `${value}`; | ||
} | ||
if (typeof value === "boolean") { | ||
return value ? "true" : "false"; | ||
} | ||
return "<unsupported type>"; | ||
} | ||
function extraProps(details) { | ||
const extra = new Map(); | ||
for (const [key, value] of Object.entries(details)) { | ||
if (isUnknownRequestProperty(key)) { | ||
extra.set(key, toString(value)); | ||
} | ||
} | ||
return Object.fromEntries(extra.entries()); | ||
} | ||
function createRemoteClient(options) { | ||
@@ -79,3 +138,3 @@ // TODO(#207): Remove this when we can default the transport | ||
const sdkStack = ArcjetStackToProtocol(options?.sdkStack ?? "NODEJS"); | ||
const sdkVersion = "1.0.0-alpha.7"; | ||
const sdkVersion = "1.0.0-alpha.8"; | ||
const client = createPromiseClient(DecideService, options.transport); | ||
@@ -96,5 +155,7 @@ return Object.freeze({ | ||
headers: Object.fromEntries(details.headers.entries()), | ||
cookies: details.cookies, | ||
query: details.query, | ||
// TODO(#208): Re-add body | ||
// body: details.body, | ||
extra: details.extra, | ||
extra: extraProps(details), | ||
email: typeof details.email === "string" ? details.email : undefined, | ||
@@ -137,3 +198,3 @@ }, | ||
// body: details.body, | ||
extra: details.extra, | ||
extra: extraProps(details), | ||
email: typeof details.email === "string" ? details.email : undefined, | ||
@@ -254,5 +315,64 @@ }, | ||
} | ||
function tokenBucket(options, ...additionalOptions) { | ||
const rules = []; | ||
if (typeof options === "undefined") { | ||
return rules; | ||
} | ||
for (const opt of [options, ...additionalOptions]) { | ||
const mode = opt.mode === "LIVE" ? "LIVE" : "DRY_RUN"; | ||
const match = opt.match; | ||
const characteristics = Array.isArray(opt.characteristics) | ||
? opt.characteristics | ||
: undefined; | ||
const refillRate = opt.refillRate; | ||
const interval = duration.parse(opt.interval); | ||
const capacity = opt.capacity; | ||
rules.push({ | ||
type: "RATE_LIMIT", | ||
priority: Priority.RateLimit, | ||
mode, | ||
match, | ||
characteristics, | ||
algorithm: "TOKEN_BUCKET", | ||
refillRate, | ||
interval, | ||
capacity, | ||
}); | ||
} | ||
return rules; | ||
} | ||
function fixedWindow(options, ...additionalOptions) { | ||
const rules = []; | ||
if (typeof options === "undefined") { | ||
return rules; | ||
} | ||
for (const opt of [options, ...additionalOptions]) { | ||
const mode = opt.mode === "LIVE" ? "LIVE" : "DRY_RUN"; | ||
const match = opt.match; | ||
const characteristics = Array.isArray(opt.characteristics) | ||
? opt.characteristics | ||
: undefined; | ||
const max = opt.max; | ||
const window = duration.parse(opt.window); | ||
rules.push({ | ||
type: "RATE_LIMIT", | ||
priority: Priority.RateLimit, | ||
mode, | ||
match, | ||
characteristics, | ||
algorithm: "FIXED_WINDOW", | ||
max, | ||
window, | ||
}); | ||
} | ||
return rules; | ||
} | ||
// This is currently kept for backwards compatibility but should be removed in | ||
// favor of the fixedWindow primitive. | ||
function rateLimit(options, ...additionalOptions) { | ||
// TODO(#195): We should also have a local rate limit using an in-memory data | ||
// structure if the environment supports it | ||
return fixedWindow(options, ...additionalOptions); | ||
} | ||
function slidingWindow(options, ...additionalOptions) { | ||
const rules = []; | ||
@@ -264,2 +384,8 @@ if (typeof options === "undefined") { | ||
const mode = opt.mode === "LIVE" ? "LIVE" : "DRY_RUN"; | ||
const match = opt.match; | ||
const characteristics = Array.isArray(opt.characteristics) | ||
? opt.characteristics | ||
: undefined; | ||
const max = opt.max; | ||
const interval = duration.parse(opt.interval); | ||
rules.push({ | ||
@@ -269,7 +395,7 @@ type: "RATE_LIMIT", | ||
mode, | ||
match: opt.match, | ||
characteristics: opt.characteristics, | ||
window: opt.window, | ||
max: opt.max, | ||
timeout: opt.timeout, | ||
match, | ||
characteristics, | ||
algorithm: "SLIDING_WINDOW", | ||
max, | ||
interval, | ||
}); | ||
@@ -331,3 +457,3 @@ } | ||
const mode = opt.mode === "LIVE" ? "LIVE" : "DRY_RUN"; | ||
// TODO: Filter invalid email types (or error??) | ||
// TODO: Filter invalid bot types (or error??) | ||
const block = Array.isArray(opt.block) | ||
@@ -370,3 +496,3 @@ ? opt.block | ||
return new ArcjetRuleResult({ | ||
ttl: 60000, | ||
ttl: 60, | ||
state: "RUN", | ||
@@ -384,3 +510,3 @@ conclusion: "DENY", | ||
return new ArcjetRuleResult({ | ||
ttl: 60000, | ||
ttl: 60, | ||
state: "RUN", | ||
@@ -402,6 +528,6 @@ conclusion: "ALLOW", | ||
if (Array.isArray(options?.rateLimit)) { | ||
rateLimitRules = rateLimit(...options.rateLimit); | ||
rateLimitRules = slidingWindow(...options.rateLimit); | ||
} | ||
else { | ||
rateLimitRules = rateLimit(options?.rateLimit); | ||
rateLimitRules = slidingWindow(options?.rateLimit); | ||
} | ||
@@ -546,2 +672,3 @@ let botRules = []; | ||
runtime: runtime(), | ||
ttl: results[idx].ttl, | ||
conclusion: results[idx].conclusion, | ||
@@ -576,3 +703,3 @@ reason: results[idx].reason, | ||
if (results[idx].ttl > 0) { | ||
log.debug("Caching decision for %d milliseconds", decision.ttl, { | ||
log.debug("Caching decision for %d seconds", decision.ttl, { | ||
fingerprint, | ||
@@ -582,3 +709,3 @@ conclusion: decision.conclusion, | ||
}); | ||
blockCache.set(fingerprint, decision.reason, decision.ttl); | ||
blockCache.set(fingerprint, decision.reason, nowInSeconds() + decision.ttl); | ||
} | ||
@@ -601,4 +728,4 @@ return decision; | ||
if (decision.isDenied() && decision.ttl > 0) { | ||
log.debug("decide: Caching block locally for %d milliseconds", decision.ttl); | ||
blockCache.set(fingerprint, decision.reason, decision.ttl); | ||
log.debug("decide: Caching block locally for %d seconds", decision.ttl); | ||
blockCache.set(fingerprint, decision.reason, nowInSeconds() + decision.ttl); | ||
} | ||
@@ -624,2 +751,2 @@ return decision; | ||
export { ArcjetHeaders, Runtime, createRemoteClient, arcjet as default, defaultBaseUrl, detectBot, protectSignup, rateLimit, validateEmail }; | ||
export { ArcjetHeaders, Runtime, createRemoteClient, arcjet as default, defaultBaseUrl, detectBot, fixedWindow, protectSignup, rateLimit, slidingWindow, tokenBucket, validateEmail }; |
364
index.ts
@@ -16,3 +16,2 @@ import { | ||
ArcjetErrorDecision, | ||
ArcjetRateLimitRule, | ||
ArcjetBotRule, | ||
@@ -22,2 +21,5 @@ ArcjetRule, | ||
ArcjetRequestDetails, | ||
ArcjetTokenBucketRateLimitRule, | ||
ArcjetFixedWindowRateLimitRule, | ||
ArcjetSlidingWindowRateLimitRule, | ||
} from "@arcjet/protocol"; | ||
@@ -42,2 +44,3 @@ import { | ||
import * as analyze from "@arcjet/analyze"; | ||
import * as duration from "@arcjet/duration"; | ||
import { Logger } from "@arcjet/logger"; | ||
@@ -57,2 +60,6 @@ | ||
function nowInSeconds(): number { | ||
return Math.floor(Date.now() / 1000); | ||
} | ||
class Cache<T> { | ||
@@ -78,4 +85,4 @@ expires: Map<string, number>; | ||
set(key: string, value: T, ttl: number) { | ||
this.expires.set(key, Date.now() + ttl); | ||
set(key: string, value: T, expiresAt: number) { | ||
this.expires.set(key, expiresAt); | ||
this.data.set(key, value); | ||
@@ -85,3 +92,3 @@ } | ||
ttl(key: string): number { | ||
const now = Date.now(); | ||
const now = nowInSeconds(); | ||
const expiresAt = this.expires.get(key) ?? now; | ||
@@ -117,2 +124,6 @@ return expiresAt - now; | ||
// https://github.com/sindresorhus/type-fest/blob/017bf38ebb52df37c297324d97bcc693ec22e920/source/union-to-intersection.d.ts | ||
// IsNever: | ||
// https://github.com/sindresorhus/type-fest/blob/e02f228f6391bb2b26c32a55dfe1e3aa2386d515/source/primitive.d.ts | ||
// LiteralCheck & IsStringLiteral: | ||
// https://github.com/sindresorhus/type-fest/blob/e02f228f6391bb2b26c32a55dfe1e3aa2386d515/source/is-literal.d.ts | ||
// | ||
@@ -156,2 +167,21 @@ // Licensed: MIT License Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> | ||
: never; | ||
type IsNever<T> = [T] extends [never] ? true : false; | ||
type LiteralCheck< | ||
T, | ||
LiteralType extends | ||
| null | ||
| undefined | ||
| string | ||
| number | ||
| boolean | ||
| symbol | ||
| bigint, | ||
> = IsNever<T> extends false // Must be wider than `never` | ||
? [T] extends [LiteralType] // Must be narrower than `LiteralType` | ||
? [LiteralType] extends [T] // Cannot be wider than `LiteralType` | ||
? false | ||
: true | ||
: false | ||
: false; | ||
type IsStringLiteral<T> = LiteralCheck<T, string>; | ||
@@ -182,8 +212,71 @@ export interface RemoteClient { | ||
const baseUrlAllowed = [ | ||
"https://decide.arcjet.com", | ||
"https://decide.arcjettest.com", | ||
"https://decide.arcjet.orb.local:4082", | ||
]; | ||
export function defaultBaseUrl() { | ||
return process.env["ARCJET_BASE_URL"] | ||
? process.env["ARCJET_BASE_URL"] // If ARCJET_BASE_URL is set, use it | ||
: "https://decide.arcjet.com"; // Otherwise use the default | ||
// TODO(#90): Remove this production conditional before 1.0.0 | ||
if (process.env["NODE_ENV"] === "production") { | ||
// Use ARCJET_BASE_URL if it is set and belongs to our allowlist; otherwise | ||
// use the hardcoded default. | ||
if ( | ||
typeof process.env["ARCJET_BASE_URL"] === "string" && | ||
baseUrlAllowed.includes(process.env["ARCJET_BASE_URL"]) | ||
) { | ||
return process.env["ARCJET_BASE_URL"]; | ||
} else { | ||
return "https://decide.arcjet.com"; | ||
} | ||
} else { | ||
return process.env["ARCJET_BASE_URL"] | ||
? process.env["ARCJET_BASE_URL"] | ||
: "https://decide.arcjet.com"; | ||
} | ||
} | ||
const knownFields = [ | ||
"ip", | ||
"method", | ||
"protocol", | ||
"host", | ||
"path", | ||
"headers", | ||
"body", | ||
"email", | ||
"cookies", | ||
"query", | ||
]; | ||
function isUnknownRequestProperty(key: string) { | ||
return !knownFields.includes(key); | ||
} | ||
function toString(value: unknown) { | ||
if (typeof value === "string") { | ||
return value; | ||
} | ||
if (typeof value === "number") { | ||
return `${value}`; | ||
} | ||
if (typeof value === "boolean") { | ||
return value ? "true" : "false"; | ||
} | ||
return "<unsupported type>"; | ||
} | ||
function extraProps(details: ArcjetRequestDetails): Record<string, string> { | ||
const extra: Map<string, string> = new Map(); | ||
for (const [key, value] of Object.entries(details)) { | ||
if (isUnknownRequestProperty(key)) { | ||
extra.set(key, toString(value)); | ||
} | ||
} | ||
return Object.fromEntries(extra.entries()); | ||
} | ||
export function createRemoteClient( | ||
@@ -230,5 +323,7 @@ options?: RemoteClientOptions, | ||
headers: Object.fromEntries(details.headers.entries()), | ||
cookies: details.cookies, | ||
query: details.query, | ||
// TODO(#208): Re-add body | ||
// body: details.body, | ||
extra: details.extra, | ||
extra: extraProps(details), | ||
email: typeof details.email === "string" ? details.email : undefined, | ||
@@ -282,3 +377,3 @@ }, | ||
// body: details.body, | ||
extra: details.extra, | ||
extra: extraProps(details), | ||
email: typeof details.email === "string" ? details.email : undefined, | ||
@@ -365,11 +460,28 @@ }, | ||
export type RateLimitOptions = { | ||
type TokenBucketRateLimitOptions<Characteristics extends readonly string[]> = { | ||
mode?: ArcjetMode; | ||
match?: string; | ||
characteristics?: string[]; | ||
window: string; | ||
characteristics?: Characteristics; | ||
refillRate: number; | ||
interval: string | number; | ||
capacity: number; | ||
}; | ||
type FixedWindowRateLimitOptions<Characteristics extends readonly string[]> = { | ||
mode?: ArcjetMode; | ||
match?: string; | ||
characteristics?: Characteristics; | ||
window: string | number; | ||
max: number; | ||
timeout: string; | ||
}; | ||
type SlidingWindowRateLimitOptions<Characteristics extends readonly string[]> = | ||
{ | ||
mode?: ArcjetMode; | ||
match?: string; | ||
characteristics?: Characteristics; | ||
interval: string | number; | ||
max: number; | ||
}; | ||
/** | ||
@@ -477,2 +589,27 @@ * Bot detection is disabled by default. The `bots` configuration block allows | ||
// Primitives and Products external names for Rules even though they are defined | ||
// the same. | ||
// See ExtraProps below for further explanation on why we define them like this. | ||
export type Primitive<Props extends PlainObject = {}> = ArcjetRule<Props>[]; | ||
export type Product<Props extends PlainObject = {}> = ArcjetRule<Props>[]; | ||
// User-defined characteristics alter the required props of an ArcjetRequest | ||
// Note: If a user doesn't provide the object literal to our primitives | ||
// directly, we fallback to no required props. They can opt-in by adding the | ||
// `as const` suffix to the characteristics array. | ||
type PropsForCharacteristic<T> = IsStringLiteral<T> extends true | ||
? T extends | ||
| "ip.src" | ||
| "http.host" | ||
| "http.method" | ||
| "http.request.uri.path" | ||
| `http.request.headers["${string}"]` | ||
| `http.request.cookie["${string}"]` | ||
| `http.request.uri.args["${string}"]` | ||
? {} | ||
: T extends string | ||
? Record<T, string | number | boolean> | ||
: never | ||
: {}; | ||
// Rules can specify they require specific props on an ArcjetRequest | ||
type PropsForRule<R> = R extends ArcjetRule<infer Props> ? Props : {}; | ||
@@ -491,11 +628,18 @@ // We theoretically support an arbitrary amount of rule flattening, | ||
/** | ||
* @property {string} ip - The IP address of the client. | ||
* @property {string} method - The HTTP method of the request. | ||
* @property {string} protocol - The protocol of the request. | ||
* @property {string} host - The host of the request. | ||
* @property {string} path - The path of the request. | ||
* @property {Headers} headers - The headers of the request. | ||
* @property {string} cookies - The string representing semicolon-separated Cookies for a request. | ||
* @property {string} query - The `?`-prefixed string representing the Query for a request. Commonly referred to as a "querystring". | ||
* @property {string} email - An email address related to the request. | ||
* @property ...extra - Extra data that might be useful for Arcjet. For example, requested tokens are specified as the `requested` property. | ||
*/ | ||
export type ArcjetRequest<Props extends PlainObject> = Simplify< | ||
Partial<ArcjetRequestDetails & Props> | ||
Partial<ArcjetRequestDetails> & Props | ||
>; | ||
// Primitives and Products are the external names for Rules even though they are defined the same | ||
// See ArcjetRequest above for the explanation on why we define them like this. | ||
export type Primitive<Props extends PlainObject = {}> = ArcjetRule<Props>[]; | ||
export type Product<Props extends PlainObject = {}> = ArcjetRule<Props>[]; | ||
function isLocalRule<Props extends PlainObject>( | ||
@@ -512,10 +656,108 @@ rule: ArcjetRule<Props>, | ||
export function rateLimit( | ||
options?: RateLimitOptions, | ||
...additionalOptions: RateLimitOptions[] | ||
): Primitive { | ||
export function tokenBucket< | ||
const Characteristics extends readonly string[] = [], | ||
>( | ||
options?: TokenBucketRateLimitOptions<Characteristics>, | ||
...additionalOptions: TokenBucketRateLimitOptions<Characteristics>[] | ||
): Primitive< | ||
Simplify< | ||
UnionToIntersection< | ||
{ requested: number } | PropsForCharacteristic<Characteristics[number]> | ||
> | ||
> | ||
> { | ||
const rules: ArcjetTokenBucketRateLimitRule<{ requested: number }>[] = []; | ||
if (typeof options === "undefined") { | ||
return rules; | ||
} | ||
for (const opt of [options, ...additionalOptions]) { | ||
const mode = opt.mode === "LIVE" ? "LIVE" : "DRY_RUN"; | ||
const match = opt.match; | ||
const characteristics = Array.isArray(opt.characteristics) | ||
? opt.characteristics | ||
: undefined; | ||
const refillRate = opt.refillRate; | ||
const interval = duration.parse(opt.interval); | ||
const capacity = opt.capacity; | ||
rules.push({ | ||
type: "RATE_LIMIT", | ||
priority: Priority.RateLimit, | ||
mode, | ||
match, | ||
characteristics, | ||
algorithm: "TOKEN_BUCKET", | ||
refillRate, | ||
interval, | ||
capacity, | ||
}); | ||
} | ||
return rules; | ||
} | ||
export function fixedWindow< | ||
const Characteristics extends readonly string[] = [], | ||
>( | ||
options?: FixedWindowRateLimitOptions<Characteristics>, | ||
...additionalOptions: FixedWindowRateLimitOptions<Characteristics>[] | ||
): Primitive< | ||
Simplify<UnionToIntersection<PropsForCharacteristic<Characteristics[number]>>> | ||
> { | ||
const rules: ArcjetFixedWindowRateLimitRule<{}>[] = []; | ||
if (typeof options === "undefined") { | ||
return rules; | ||
} | ||
for (const opt of [options, ...additionalOptions]) { | ||
const mode = opt.mode === "LIVE" ? "LIVE" : "DRY_RUN"; | ||
const match = opt.match; | ||
const characteristics = Array.isArray(opt.characteristics) | ||
? opt.characteristics | ||
: undefined; | ||
const max = opt.max; | ||
const window = duration.parse(opt.window); | ||
rules.push({ | ||
type: "RATE_LIMIT", | ||
priority: Priority.RateLimit, | ||
mode, | ||
match, | ||
characteristics, | ||
algorithm: "FIXED_WINDOW", | ||
max, | ||
window, | ||
}); | ||
} | ||
return rules; | ||
} | ||
// This is currently kept for backwards compatibility but should be removed in | ||
// favor of the fixedWindow primitive. | ||
export function rateLimit<const Characteristics extends readonly string[] = []>( | ||
options?: FixedWindowRateLimitOptions<Characteristics>, | ||
...additionalOptions: FixedWindowRateLimitOptions<Characteristics>[] | ||
): Primitive< | ||
Simplify<UnionToIntersection<PropsForCharacteristic<Characteristics[number]>>> | ||
> { | ||
// TODO(#195): We should also have a local rate limit using an in-memory data | ||
// structure if the environment supports it | ||
return fixedWindow(options, ...additionalOptions); | ||
} | ||
const rules: ArcjetRateLimitRule<{}>[] = []; | ||
export function slidingWindow< | ||
const Characteristics extends readonly string[] = [], | ||
>( | ||
options?: SlidingWindowRateLimitOptions<Characteristics>, | ||
...additionalOptions: SlidingWindowRateLimitOptions<Characteristics>[] | ||
): Primitive< | ||
Simplify<UnionToIntersection<PropsForCharacteristic<Characteristics[number]>>> | ||
> { | ||
const rules: ArcjetSlidingWindowRateLimitRule<{}>[] = []; | ||
@@ -528,3 +770,10 @@ if (typeof options === "undefined") { | ||
const mode = opt.mode === "LIVE" ? "LIVE" : "DRY_RUN"; | ||
const match = opt.match; | ||
const characteristics = Array.isArray(opt.characteristics) | ||
? opt.characteristics | ||
: undefined; | ||
const max = opt.max; | ||
const interval = duration.parse(opt.interval); | ||
rules.push({ | ||
@@ -534,7 +783,7 @@ type: "RATE_LIMIT", | ||
mode, | ||
match: opt.match, | ||
characteristics: opt.characteristics, | ||
window: opt.window, | ||
max: opt.max, | ||
timeout: opt.timeout, | ||
match, | ||
characteristics, | ||
algorithm: "SLIDING_WINDOW", | ||
max, | ||
interval, | ||
}); | ||
@@ -620,3 +869,3 @@ } | ||
const mode = opt.mode === "LIVE" ? "LIVE" : "DRY_RUN"; | ||
// TODO: Filter invalid email types (or error??) | ||
// TODO: Filter invalid bot types (or error??) | ||
const block = Array.isArray(opt.block) | ||
@@ -684,3 +933,3 @@ ? opt.block | ||
return new ArcjetRuleResult({ | ||
ttl: 60000, | ||
ttl: 60, | ||
state: "RUN", | ||
@@ -697,3 +946,3 @@ conclusion: "DENY", | ||
return new ArcjetRuleResult({ | ||
ttl: 60000, | ||
ttl: 60, | ||
state: "RUN", | ||
@@ -714,4 +963,6 @@ conclusion: "ALLOW", | ||
export type ProtectSignupOptions = { | ||
rateLimit?: RateLimitOptions | RateLimitOptions[]; | ||
export type ProtectSignupOptions<Characteristics extends string[]> = { | ||
rateLimit?: | ||
| SlidingWindowRateLimitOptions<Characteristics> | ||
| SlidingWindowRateLimitOptions<Characteristics>[]; | ||
bots?: BotOptions | BotOptions[]; | ||
@@ -721,10 +972,16 @@ email?: EmailOptions | EmailOptions[]; | ||
export function protectSignup( | ||
options?: ProtectSignupOptions, | ||
): Product<{ email: string }> { | ||
export function protectSignup<const Characteristics extends string[] = []>( | ||
options?: ProtectSignupOptions<Characteristics>, | ||
): Product< | ||
Simplify< | ||
UnionToIntersection< | ||
{ email: string } | PropsForCharacteristic<Characteristics[number]> | ||
> | ||
> | ||
> { | ||
let rateLimitRules: Primitive<{}> = []; | ||
if (Array.isArray(options?.rateLimit)) { | ||
rateLimitRules = rateLimit(...options.rateLimit); | ||
rateLimitRules = slidingWindow(...options.rateLimit); | ||
} else { | ||
rateLimitRules = rateLimit(options?.rateLimit); | ||
rateLimitRules = slidingWindow(options?.rateLimit); | ||
} | ||
@@ -739,3 +996,3 @@ | ||
let emailRules: Primitive<{}> = []; | ||
let emailRules: Primitive<{ email: string }> = []; | ||
if (Array.isArray(options?.email)) { | ||
@@ -776,11 +1033,3 @@ emailRules = validateEmail(...options.email); | ||
* | ||
* @param {ArcjetRequest} request - The details about the request that Arcjet needs to make a decision. | ||
* @param {string} request.ip - The IP address of the client. | ||
* @param {string} request.method - The HTTP method of the request. | ||
* @param {string} request.protocol - The protocol of the request. | ||
* @param {string} request.host - The host of the request. | ||
* @param {string} request.path - The path of the request. | ||
* @param {Headers} request.headers - The headers of the request. | ||
* @param request.extra - Extra data to send to the Arcjet API. | ||
* | ||
* @param {ArcjetRequest} request - Details about the {@link ArcjetRequest} that Arcjet needs to make a decision. | ||
* @returns An {@link ArcjetDecision} indicating Arcjet's decision about the request. | ||
@@ -943,2 +1192,3 @@ */ | ||
runtime: runtime(), | ||
ttl: results[idx].ttl, | ||
conclusion: results[idx].conclusion, | ||
@@ -982,3 +1232,3 @@ reason: results[idx].reason, | ||
if (results[idx].ttl > 0) { | ||
log.debug("Caching decision for %d milliseconds", decision.ttl, { | ||
log.debug("Caching decision for %d seconds", decision.ttl, { | ||
fingerprint, | ||
@@ -989,3 +1239,7 @@ conclusion: decision.conclusion, | ||
blockCache.set(fingerprint, decision.reason, decision.ttl); | ||
blockCache.set( | ||
fingerprint, | ||
decision.reason, | ||
nowInSeconds() + decision.ttl, | ||
); | ||
} | ||
@@ -1018,7 +1272,11 @@ | ||
log.debug( | ||
"decide: Caching block locally for %d milliseconds", | ||
"decide: Caching block locally for %d seconds", | ||
decision.ttl, | ||
); | ||
blockCache.set(fingerprint, decision.reason, decision.ttl); | ||
blockCache.set( | ||
fingerprint, | ||
decision.reason, | ||
nowInSeconds() + decision.ttl, | ||
); | ||
} | ||
@@ -1025,0 +1283,0 @@ |
{ | ||
"name": "arcjet", | ||
"version": "1.0.0-alpha.7", | ||
"version": "1.0.0-alpha.8", | ||
"description": "Arcjet TypeScript and JavaScript SDK core", | ||
@@ -34,13 +34,14 @@ "license": "Apache-2.0", | ||
"dependencies": { | ||
"@arcjet/analyze": "1.0.0-alpha.7", | ||
"@arcjet/logger": "1.0.0-alpha.7", | ||
"@arcjet/protocol": "1.0.0-alpha.7" | ||
"@arcjet/analyze": "1.0.0-alpha.8", | ||
"@arcjet/duration": "1.0.0-alpha.8", | ||
"@arcjet/logger": "1.0.0-alpha.8", | ||
"@arcjet/protocol": "1.0.0-alpha.8" | ||
}, | ||
"devDependencies": { | ||
"@arcjet/eslint-config": "1.0.0-alpha.7", | ||
"@arcjet/rollup-config": "1.0.0-alpha.7", | ||
"@arcjet/tsconfig": "1.0.0-alpha.7", | ||
"@edge-runtime/jest-environment": "2.3.7", | ||
"@arcjet/eslint-config": "1.0.0-alpha.8", | ||
"@arcjet/rollup-config": "1.0.0-alpha.8", | ||
"@arcjet/tsconfig": "1.0.0-alpha.8", | ||
"@edge-runtime/jest-environment": "2.3.9", | ||
"@jest/globals": "29.7.0", | ||
"@rollup/wasm-node": "4.9.1", | ||
"@rollup/wasm-node": "4.9.6", | ||
"@types/node": "18.18.0", | ||
@@ -47,0 +48,0 @@ "jest": "29.7.0", |
@@ -17,6 +17,5 @@ <a href="https://arcjet.com" target="_arcjet-home"> | ||
[Arcjet][arcjet] helps developers protect their apps. Installed as an SDK, it | ||
provides a set of core primitives such as rate limiting and bot protection. | ||
These can be used independently or combined to create a set of layered defenses, | ||
such as signup form protection. | ||
[Arcjet][arcjet] helps developers protect their apps in just a few lines of | ||
code. Implement rate limiting, bot protection, email verification & defend | ||
against common attacks. | ||
@@ -48,3 +47,6 @@ This is the [Arcjet][arcjet] TypeScript and JavaScript SDK core. | ||
const aj = arcjet({ | ||
key: "ajkey_mykey", | ||
// Get your site key from https://app.arcjet.com | ||
// and set it as an environment variable rather than hard coding. | ||
// See: https://www.npmjs.com/package/dotenv | ||
key: process.env.ARCJET_KEY, | ||
rules: [], | ||
@@ -64,7 +66,12 @@ client: createRemoteClient({ | ||
// Construct an object with Arcjet request details | ||
const path = new URL(req.url || "", `http://${req.headers.host}`); | ||
const details = { | ||
ip: req.socket.remoteAddress, | ||
method: req.method, | ||
host: req.headers.host, | ||
path: path.pathname, | ||
}; | ||
const decision = await aj.protect(details); | ||
console.log(decision); | ||
@@ -71,0 +78,0 @@ if (decision.isDenied()) { |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
92542
2091
98
4
11
+ Added@arcjet/analyze@1.0.0-alpha.8(transitive)
+ Added@arcjet/logger@1.0.0-alpha.8(transitive)
+ Added@arcjet/protocol@1.0.0-alpha.8(transitive)
+ Added@bufbuild/protobuf@1.7.2(transitive)
+ Added@connectrpc/connect@1.3.0(transitive)
+ Addedtypeid-js@0.5.0(transitive)
+ Addeduuidv7@0.6.3(transitive)
- Removed@arcjet/analyze@1.0.0-alpha.7(transitive)
- Removed@arcjet/logger@1.0.0-alpha.7(transitive)
- Removed@arcjet/protocol@1.0.0-alpha.7(transitive)
- Removed@bufbuild/protobuf@1.6.0(transitive)
- Removed@connectrpc/connect@1.2.0(transitive)
- Removedtypeid-js@0.3.0(transitive)
- Removeduuidv7@0.4.4(transitive)
Updated@arcjet/logger@1.0.0-alpha.8