diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 2abcd4ed9..a1eb1d1bf 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -8,6 +8,7 @@ "arcjet-sveltekit": "1.0.0-alpha.13", "decorate": "1.0.0-alpha.13", "duration": "1.0.0-alpha.13", + "env": "1.0.0-alpha.13", "eslint-config": "1.0.0-alpha.13", "headers": "1.0.0-alpha.13", "ip": "1.0.0-alpha.13", diff --git a/.github/release-please-config.json b/.github/release-please-config.json index d3c1ae5fd..43f7db0c3 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -61,6 +61,10 @@ "component": "@arcjet/duration", "skip-github-release": true }, + "env": { + "component": "@arcjet/env", + "skip-github-release": true + }, "eslint-config": { "component": "@arcjet/eslint-config", "skip-github-release": true diff --git a/README.md b/README.md index 1175a3190..cd78bd63d 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ find a specific one through the categories and descriptions below. - [`@arcjet/runtime`](./runtime/README.md): Runtime detection. - [`@arcjet/sprintf`](./sprintf/README.md): Platform-independent replacement for `util.format`. +- [`@arcjet/env`](./env/README.md): Environment detection for Arcjet variables. ### Internal development diff --git a/arcjet-bun/index.ts b/arcjet-bun/index.ts index de6681a3d..df6e2a17e 100644 --- a/arcjet-bun/index.ts +++ b/arcjet-bun/index.ts @@ -9,7 +9,6 @@ import core, { ExtraProps, RemoteClient, RemoteClientOptions, - defaultBaseUrl, createRemoteClient, Arcjet, } from "arcjet"; @@ -17,6 +16,14 @@ import findIP from "@arcjet/ip"; import ArcjetHeaders from "@arcjet/headers"; import type { Server } from "bun"; import { env } from "bun"; +import { + baseUrl, + isDevelopment, + isProduction, + logLevel, + platform, +} from "@arcjet/env"; +import { Logger } from "@arcjet/logger"; // Re-export all named exports from the generic SDK export * from "arcjet"; @@ -59,25 +66,35 @@ type PlainObject = { }; export function createBunRemoteClient( - options?: RemoteClientOptions, + options?: Partial, ): RemoteClient { // The base URL for the Arcjet API. Will default to the standard production // API unless environment variable `ARCJET_BASE_URL` is set. - const baseUrl = options?.baseUrl ?? defaultBaseUrl(); + const url = options?.baseUrl ?? baseUrl(env); + + // The timeout for the Arcjet API in milliseconds. This is set to a low value + // in production so calls fail open. + const timeout = options?.timeout ?? (isProduction(env) ? 500 : 1000); // Transport is the HTTP client that the client uses to make requests. const transport = options?.transport ?? createConnectTransport({ - baseUrl, + baseUrl: url, httpVersion: "1.1", }); - // TODO(#223): Do we want to allow overrides to either of these? If not, we should probably define a separate type for `options` + // TODO(#223): Create separate options type to exclude these const sdkStack = "BUN"; const sdkVersion = "__ARCJET_SDK_VERSION__"; - return createRemoteClient({ ...options, transport, sdkStack, sdkVersion }); + return createRemoteClient({ + transport, + baseUrl: url, + timeout, + sdkStack, + sdkVersion, + }); } /** @@ -131,12 +148,6 @@ export interface ArcjetBun { ) => Response | Promise; } -function detectPlatform() { - if (typeof env["FLY_APP_NAME"] === "string" && env["FLY_APP_NAME"] !== "") { - return "fly-io" as const; - } -} - // This is provided with an `ipCache` where it attempts to lookup the IP. This // is primarily a workaround to the API design in Bun that requires access to // the `Server` to lookup an IP. @@ -156,12 +167,12 @@ function toArcjetRequest( ip: ipCache.get(request), }, headers, - { platform: detectPlatform() }, + { platform: platform(env) }, ); if (ip === "") { // If the `ip` is empty but we're in development mode, we default the IP // so the request doesn't fail. - if (env.NODE_ENV === "development" || env.ARCJET_ENV === "development") { + if (isDevelopment(env)) { // TODO: Log that the fingerprint is being overridden once the adapter // constructs the logger ip = "127.0.0.1"; @@ -246,7 +257,13 @@ export default function arcjet( ): ArcjetBun>> { const client = options.client ?? createBunRemoteClient(); - const aj = core({ ...options, client }); + const log = options.log + ? options.log + : new Logger({ + level: logLevel(env), + }); + + const aj = core({ ...options, client, log }); return withClient(aj); } diff --git a/arcjet-bun/package.json b/arcjet-bun/package.json index 28f15bd77..3887c3478 100644 --- a/arcjet-bun/package.json +++ b/arcjet-bun/package.json @@ -38,8 +38,10 @@ "test": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests" }, "dependencies": { + "@arcjet/env": "1.0.0-alpha.13", "@arcjet/headers": "1.0.0-alpha.13", "@arcjet/ip": "1.0.0-alpha.13", + "@arcjet/logger": "1.0.0-alpha.13", "@connectrpc/connect-node": "1.4.0", "arcjet": "1.0.0-alpha.13" }, diff --git a/arcjet-next/index.ts b/arcjet-next/index.ts index 8422bc55c..49155da96 100644 --- a/arcjet-next/index.ts +++ b/arcjet-next/index.ts @@ -16,12 +16,19 @@ import arcjet, { ExtraProps, RemoteClient, RemoteClientOptions, - defaultBaseUrl, createRemoteClient, Arcjet, } from "arcjet"; import findIP from "@arcjet/ip"; import ArcjetHeaders from "@arcjet/headers"; +import { + baseUrl, + isDevelopment, + isProduction, + logLevel, + platform, +} from "@arcjet/env"; +import { Logger } from "@arcjet/logger"; // Re-export all named exports from the generic SDK export * from "arcjet"; @@ -64,11 +71,15 @@ type PlainObject = { }; export function createNextRemoteClient( - options?: RemoteClientOptions, + options?: Partial, ): RemoteClient { // The base URL for the Arcjet API. Will default to the standard production // API unless environment variable `ARCJET_BASE_URL` is set. - const baseUrl = options?.baseUrl ?? defaultBaseUrl(); + const url = options?.baseUrl ?? baseUrl(process.env); + + // The timeout for the Arcjet API in milliseconds. This is set to a low value + // in production so calls fail open. + const timeout = options?.timeout ?? (isProduction(process.env) ? 500 : 1000); // Transport is the HTTP client that the client uses to make requests. // The Connect Node client doesn't work on edge runtimes: https://github.com/bufbuild/connect-es/pull/589 @@ -76,7 +87,7 @@ export function createNextRemoteClient( const transport = options?.transport ?? createConnectTransport({ - baseUrl, + baseUrl: url, interceptors: [ /** * Ensures redirects are followed to properly support the Next.js/Vercel Edge @@ -92,11 +103,17 @@ export function createNextRemoteClient( fetch, }); - // TODO(#223): Do we want to allow overrides to either of these? If not, we should probably define a separate type for `options` + // TODO(#223): Create separate options type to exclude these const sdkStack = "NEXTJS"; const sdkVersion = "__ARCJET_SDK_VERSION__"; - return createRemoteClient({ ...options, transport, sdkStack, sdkVersion }); + return createRemoteClient({ + transport, + baseUrl: url, + timeout, + sdkStack, + sdkVersion, + }); } // Interface of fields that the Arcjet Next.js SDK expects on `Request` objects. @@ -193,15 +210,6 @@ export interface ArcjetNext { ): ArcjetNext>>; } -function detectPlatform() { - if ( - typeof process.env["FLY_APP_NAME"] === "string" && - process.env["FLY_APP_NAME"] !== "" - ) { - return "fly-io" as const; - } -} - function toArcjetRequest( request: ArcjetNextRequest, props: Props, @@ -209,14 +217,11 @@ function toArcjetRequest( // We construct an ArcjetHeaders to normalize over Headers const headers = new ArcjetHeaders(request.headers); - let ip = findIP(request, headers, { platform: detectPlatform() }); + let ip = findIP(request, headers, { platform: platform(process.env) }); if (ip === "") { // If the `ip` is empty but we're in development mode, we default the IP // so the request doesn't fail. - if ( - process.env["NODE_ENV"] === "development" || - process.env["ARCJET_ENV"] === "development" - ) { + if (isDevelopment(process.env)) { // TODO: Log that the fingerprint is being overridden once the adapter // constructs the logger ip = "127.0.0.1"; @@ -334,7 +339,13 @@ export default function arcjetNext( ): ArcjetNext>> { const client = options.client ?? createNextRemoteClient(); - const aj = arcjet({ ...options, client }); + const log = options.log + ? options.log + : new Logger({ + level: logLevel(process.env), + }); + + const aj = arcjet({ ...options, client, log }); return withClient(aj); } diff --git a/arcjet-next/package.json b/arcjet-next/package.json index ad8e36731..6d894c041 100644 --- a/arcjet-next/package.json +++ b/arcjet-next/package.json @@ -40,8 +40,10 @@ "test": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests" }, "dependencies": { + "@arcjet/env": "1.0.0-alpha.13", "@arcjet/headers": "1.0.0-alpha.13", "@arcjet/ip": "1.0.0-alpha.13", + "@arcjet/logger": "1.0.0-alpha.13", "@connectrpc/connect-web": "1.4.0", "arcjet": "1.0.0-alpha.13" }, diff --git a/arcjet-node/index.ts b/arcjet-node/index.ts index 69b9b45c8..3efaf7037 100644 --- a/arcjet-node/index.ts +++ b/arcjet-node/index.ts @@ -8,12 +8,19 @@ import core, { ExtraProps, RemoteClient, RemoteClientOptions, - defaultBaseUrl, createRemoteClient, Arcjet, } from "arcjet"; import findIP from "@arcjet/ip"; import ArcjetHeaders from "@arcjet/headers"; +import { + baseUrl, + isDevelopment, + isProduction, + logLevel, + platform, +} from "@arcjet/env"; +import { Logger } from "@arcjet/logger"; // Re-export all named exports from the generic SDK export * from "arcjet"; @@ -56,25 +63,35 @@ type PlainObject = { }; export function createNodeRemoteClient( - options?: RemoteClientOptions, + options?: Partial, ): RemoteClient { // The base URL for the Arcjet API. Will default to the standard production // API unless environment variable `ARCJET_BASE_URL` is set. - const baseUrl = options?.baseUrl ?? defaultBaseUrl(); + const url = options?.baseUrl ?? baseUrl(process.env); + + // The timeout for the Arcjet API in milliseconds. This is set to a low value + // in production so calls fail open. + const timeout = options?.timeout ?? (isProduction(process.env) ? 500 : 1000); // Transport is the HTTP client that the client uses to make requests. const transport = options?.transport ?? createConnectTransport({ - baseUrl, + baseUrl: url, httpVersion: "2", }); - // TODO(#223): Do we want to allow overrides to either of these? If not, we should probably define a separate type for `options` + // TODO(#223): Create separate options type to exclude these const sdkStack = "NODEJS"; const sdkVersion = "__ARCJET_SDK_VERSION__"; - return createRemoteClient({ ...options, transport, sdkStack, sdkVersion }); + return createRemoteClient({ + transport, + baseUrl: url, + timeout, + sdkStack, + sdkVersion, + }); } // Interface of fields that the Arcjet Node.js SDK expects on `IncomingMessage` @@ -133,15 +150,6 @@ export interface ArcjetNode { ): ArcjetNode>>; } -function detectPlatform() { - if ( - typeof process.env["FLY_APP_NAME"] === "string" && - process.env["FLY_APP_NAME"] !== "" - ) { - return "fly-io" as const; - } -} - function toArcjetRequest( request: ArcjetNodeRequest, props: Props, @@ -152,14 +160,11 @@ function toArcjetRequest( // We construct an ArcjetHeaders to normalize over Headers const headers = new ArcjetHeaders(request.headers); - let ip = findIP(request, headers, { platform: detectPlatform() }); + let ip = findIP(request, headers, { platform: platform(process.env) }); if (ip === "") { // If the `ip` is empty but we're in development mode, we default the IP // so the request doesn't fail. - if ( - process.env["NODE_ENV"] === "development" || - process.env["ARCJET_ENV"] === "development" - ) { + if (isDevelopment(process.env)) { // TODO: Log that the fingerprint is being overridden once the adapter // constructs the logger ip = "127.0.0.1"; @@ -246,7 +251,13 @@ export default function arcjet( ): ArcjetNode>> { const client = options.client ?? createNodeRemoteClient(); - const aj = core({ ...options, client }); + const log = options.log + ? options.log + : new Logger({ + level: logLevel(process.env), + }); + + const aj = core({ ...options, client, log }); return withClient(aj); } diff --git a/arcjet-node/package.json b/arcjet-node/package.json index f8f4c8128..ca355f7ed 100644 --- a/arcjet-node/package.json +++ b/arcjet-node/package.json @@ -40,8 +40,10 @@ "test": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests" }, "dependencies": { + "@arcjet/env": "1.0.0-alpha.13", "@arcjet/headers": "1.0.0-alpha.13", "@arcjet/ip": "1.0.0-alpha.13", + "@arcjet/logger": "1.0.0-alpha.13", "@connectrpc/connect-node": "1.4.0", "arcjet": "1.0.0-alpha.13" }, diff --git a/arcjet-sveltekit/index.ts b/arcjet-sveltekit/index.ts index bf3a77a48..f58549d53 100644 --- a/arcjet-sveltekit/index.ts +++ b/arcjet-sveltekit/index.ts @@ -9,7 +9,6 @@ import core, { ExtraProps, RemoteClient, RemoteClientOptions, - defaultBaseUrl, createRemoteClient, Arcjet, } from "arcjet"; @@ -17,6 +16,14 @@ import findIP from "@arcjet/ip"; import ArcjetHeaders from "@arcjet/headers"; import { runtime } from "@arcjet/runtime"; import { env } from "$env/dynamic/private"; +import { + baseUrl, + isDevelopment, + isProduction, + logLevel, + platform, +} from "@arcjet/env"; +import { Logger } from "@arcjet/logger"; // Re-export all named exports from the generic SDK export * from "arcjet"; @@ -87,20 +94,30 @@ function defaultTransport(baseUrl: string) { } export function createSvelteKitRemoteClient( - options?: RemoteClientOptions, + options?: Partial, ): RemoteClient { // The base URL for the Arcjet API. Will default to the standard production // API unless environment variable `ARCJET_BASE_URL` is set. - const baseUrl = options?.baseUrl ?? defaultBaseUrl(); + const url = options?.baseUrl ?? baseUrl(env); + + // The timeout for the Arcjet API in milliseconds. This is set to a low value + // in production so calls fail open. + const timeout = options?.timeout ?? (isProduction(env) ? 500 : 1000); // Transport is the HTTP client that the client uses to make requests. - const transport = options?.transport ?? defaultTransport(baseUrl); + const transport = options?.transport ?? defaultTransport(url); - // TODO(#223): Do we want to allow overrides to either of these? If not, we should probably define a separate type for `options` + // TODO(#223): Create separate options type to exclude these const sdkStack = "SVELTEKIT"; const sdkVersion = "__ARCJET_SDK_VERSION__"; - return createRemoteClient({ ...options, transport, sdkStack, sdkVersion }); + return createRemoteClient({ + transport, + baseUrl: url, + timeout, + sdkStack, + sdkVersion, + }); } interface Cookies { @@ -157,12 +174,6 @@ export interface ArcjetSvelteKit { ): ArcjetSvelteKit>>; } -function detectPlatform() { - if (typeof env["FLY_APP_NAME"] === "string" && env["FLY_APP_NAME"] !== "") { - return "fly-io" as const; - } -} - function toArcjetRequest( event: ArcjetSvelteKitRequestEvent, props: Props, @@ -177,12 +188,12 @@ function toArcjetRequest( ip: event.getClientAddress(), }, headers, - { platform: detectPlatform() }, + { platform: platform(env) }, ); if (ip === "") { // If the `ip` is empty but we're in development mode, we default the IP // so the request doesn't fail. - if (env.NODE_ENV === "development" || env.ARCJET_ENV === "development") { + if (isDevelopment(env)) { // TODO: Log that the fingerprint is being overridden once the adapter // constructs the logger ip = "127.0.0.1"; @@ -246,7 +257,13 @@ export default function arcjet( ): ArcjetSvelteKit>> { const client = options.client ?? createSvelteKitRemoteClient(); - const aj = core({ ...options, client }); + const log = options.log + ? options.log + : new Logger({ + level: logLevel(env), + }); + + const aj = core({ ...options, client, log }); return withClient(aj); } diff --git a/arcjet-sveltekit/package.json b/arcjet-sveltekit/package.json index e0a1a1b27..28c5436f9 100644 --- a/arcjet-sveltekit/package.json +++ b/arcjet-sveltekit/package.json @@ -40,8 +40,10 @@ "test": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests" }, "dependencies": { + "@arcjet/env": "1.0.0-alpha.13", "@arcjet/headers": "1.0.0-alpha.13", "@arcjet/ip": "1.0.0-alpha.13", + "@arcjet/logger": "1.0.0-alpha.13", "@arcjet/runtime": "1.0.0-alpha.13", "@connectrpc/connect-node": "1.4.0", "@connectrpc/connect-web": "1.4.0", diff --git a/arcjet/README.md b/arcjet/README.md index 5e9a05f7f..0644011cd 100644 --- a/arcjet/README.md +++ b/arcjet/README.md @@ -41,7 +41,8 @@ npm install -S arcjet ```ts import http from "http"; -import arcjet, { createRemoteClient, defaultBaseUrl } from "arcjet"; +import arcjet, { createRemoteClient } from "arcjet"; +import { baseUrl } from "@arcjet/env"; import { createConnectTransport } from "@connectrpc/connect-node"; const aj = arcjet({ @@ -52,7 +53,7 @@ const aj = arcjet({ rules: [], client: createRemoteClient({ transport: createConnectTransport({ - baseUrl: defaultBaseUrl(), + baseUrl: baseUrl(process.env), httpVersion: "2", }), }), diff --git a/arcjet/index.ts b/arcjet/index.ts index 93d56a365..ba2451599 100644 --- a/arcjet/index.ts +++ b/arcjet/index.ts @@ -40,7 +40,6 @@ import { } from "@arcjet/protocol/proto.js"; import * as analyze from "@arcjet/analyze"; import * as duration from "@arcjet/duration"; -import { Logger, type LogLevel } from "@arcjet/logger"; import ArcjetHeaders from "@arcjet/headers"; import { runtime } from "@arcjet/runtime"; @@ -193,61 +192,13 @@ export interface RemoteClient { } export type RemoteClientOptions = { - transport?: Transport; - baseUrl?: string; - timeout?: number; - sdkStack?: ArcjetStack; - sdkVersion?: string; + transport: Transport; + baseUrl: string; + timeout: number; + sdkStack: ArcjetStack; + sdkVersion: string; }; -const baseUrlAllowed = [ - "https://decide.arcjet.com", - "https://decide.arcjettest.com", - "https://fly.decide.arcjet.com", - "https://fly.decide.arcjettest.com", - "https://decide.arcjet.orb.local:4082", -]; - -export function defaultBaseUrl() { - // 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"]; - } - - // If we're running on fly.io, use the Arcjet Decide Service hosted on fly - // Ref: https://fly.io/docs/machines/runtime-environment/#environment-variables - if ( - typeof process.env["FLY_APP_NAME"] === "string" && - process.env["FLY_APP_NAME"] !== "" - ) { - return "https://fly.decide.arcjet.com"; - } - - return "https://decide.arcjet.com"; - } else { - if (process.env["ARCJET_BASE_URL"]) { - return process.env["ARCJET_BASE_URL"]; - } - - // If we're running on fly.io, use the Arcjet Decide Service hosted on fly - // Ref: https://fly.io/docs/machines/runtime-environment/#environment-variables - if ( - typeof process.env["FLY_APP_NAME"] === "string" && - process.env["FLY_APP_NAME"] !== "" - ) { - return "https://fly.decide.arcjet.com"; - } - - return "https://decide.arcjet.com"; - } -} - const knownFields = [ "ip", "method", @@ -293,28 +244,12 @@ function extraProps( return Object.fromEntries(extra.entries()); } -export function createRemoteClient( - options?: RemoteClientOptions, -): RemoteClient { - // TODO(#207): Remove this when we can default the transport - if (typeof options?.transport === "undefined") { - throw new Error("Transport must be defined"); - } - - // The base URL for the Arcjet API. Will default to the standard production - // API unless environment variable `ARCJET_BASE_URL` is set. - // TODO(#207): This is unused until we can default the transport - const baseUrl = options?.baseUrl ?? defaultBaseUrl(); +export function createRemoteClient(options: RemoteClientOptions): RemoteClient { + const { transport, sdkVersion, baseUrl, timeout } = options; - // The timeout for the Arcjet API in milliseconds. This is set to a low value - // in production so calls fail open. - const timeout = - options?.timeout ?? (process.env["NODE_ENV"] === "production" ? 500 : 1000); + const sdkStack = ArcjetStackToProtocol(options.sdkStack); - const sdkStack = ArcjetStackToProtocol(options?.sdkStack ?? "NODEJS"); - const sdkVersion = "__ARCJET_SDK_VERSION__"; - - const client = createPromiseClient(DecideService, options.transport); + const client = createPromiseClient(DecideService, transport); return Object.freeze({ async decide( @@ -1059,20 +994,6 @@ export interface Arcjet { ): Arcjet>>; } -function getEnvLogLevel(): LogLevel { - const level = process.env["ARCJET_LOG_LEVEL"]; - switch (level) { - case "debug": - case "info": - case "warn": - case "error": - return level; - default: - // Default to warn if not set - return "warn"; - } -} - /** * Create a new Arcjet client with the specified {@link ArcjetOptions}. * @@ -1082,23 +1003,23 @@ export default function arcjet< const Rules extends [...(Primitive | Product)[]] = [], >(options: ArcjetOptions): Arcjet>> { // We destructure here to make the function signature neat when viewed by consumers - const { key, rules, client } = options; + const { key, rules } = options; const rt = runtime(); - const log = options.log - ? options.log - : new Logger({ - level: getEnvLogLevel(), - }); + + // TODO: Separate the ArcjetOptions from the SDK Options + // It is currently optional in the options so users can override it via an SDK + if (typeof options.log === "undefined") { + throw new Error("Log is required"); + } + const log = options.log; // TODO(#207): Remove this when we can default the transport so client is not required // It is currently optional in the options so the Next SDK can override it for the user - if (typeof client === "undefined") { + if (typeof options.client === "undefined") { throw new Error("Client is required"); } - // This is reassigned to help TypeScript's type inference, as it loses the - // type narrowing of the above `if` statement when using from inside `protect` - const remoteClient = client; + const client = options.client; // A local cache of block decisions. Might be emphemeral per request, // depending on the way the runtime works, but it's worth a try. @@ -1176,7 +1097,7 @@ export default function arcjet< results: [], }); - remoteClient.report( + client.report( context, details, decision, @@ -1225,7 +1146,7 @@ export default function arcjet< results, }); - remoteClient.report(context, details, decision, rules); + client.report(context, details, decision, rules); log.debug( { @@ -1299,7 +1220,7 @@ export default function arcjet< // Only a DENY decision is reported to avoid creating 2 entries for a // request. Upon ALLOW, the `decide` call will create an entry for the // request. - remoteClient.report(context, details, decision, rules); + client.report(context, details, decision, rules); // If we're not in DRY_RUN mode, we want to cache non-zero TTL results // and return this DENY decision. @@ -1340,7 +1261,7 @@ export default function arcjet< // fail open. try { log.time?.("decideApi"); - const decision = await remoteClient.decide(context, details, rules); + const decision = await client.decide(context, details, rules); log.timeEnd?.("decideApi"); // If the decision is to block and we have a non-zero TTL, we cache the @@ -1367,7 +1288,7 @@ export default function arcjet< results, }); - remoteClient.report(context, details, decision, rules); + client.report(context, details, decision, rules); return decision; } finally { diff --git a/arcjet/test/index.edge.test.ts b/arcjet/test/index.edge.test.ts index 9486f6ae4..f6ed561d1 100644 --- a/arcjet/test/index.edge.test.ts +++ b/arcjet/test/index.edge.test.ts @@ -11,9 +11,12 @@ import arcjet, { ArcjetReason, ArcjetAllowDecision, } from "../index"; +import { Logger } from "@arcjet/logger"; class ArcjetTestReason extends ArcjetReason {} +const log = new Logger({ level: "info" }); + describe("Arcjet: Env = Edge runtime", () => { test("should create a new instance", async () => { const client = { @@ -65,6 +68,7 @@ describe("Arcjet: Env = Edge runtime", () => { protectSignup(), ], client, + log, }); const aj2 = aj.withRule(foobarbaz()); diff --git a/arcjet/test/index.node.test.ts b/arcjet/test/index.node.test.ts index 03840f6e7..6f2fe4c52 100644 --- a/arcjet/test/index.node.test.ts +++ b/arcjet/test/index.node.test.ts @@ -32,7 +32,6 @@ import arcjet, { detectBot, rateLimit, ArcjetRule, - defaultBaseUrl, validateEmail, protectSignup, createRemoteClient, @@ -167,60 +166,17 @@ class ArcjetInvalidDecision extends ArcjetDecision { const log = new Logger({ level: "info" }); -describe("defaultBaseUrl", () => { - test("uses process.env.ARCJET_BASE_URL if set and allowed", () => { - jest.replaceProperty(process, "env", { - NODE_ENV: "production", - ARCJET_BASE_URL: "https://decide.arcjet.orb.local:4082", - }); - expect(defaultBaseUrl()).toEqual("https://decide.arcjet.orb.local:4082"); - }); - - test("does not use process.env.ARCJET_BASE_URL if not allowed", () => { - jest.replaceProperty(process, "env", { - NODE_ENV: "production", - ARCJET_BASE_URL: "http://localhost:1234", - }); - expect(defaultBaseUrl()).toEqual("https://decide.arcjet.com"); - }); - - test("does not use process.env.ARCJET_BASE_URL if empty string", () => { - jest.replaceProperty(process, "env", { - NODE_ENV: "production", - ARCJET_BASE_URL: "", - }); - expect(defaultBaseUrl()).toEqual("https://decide.arcjet.com"); - }); - - test("uses production url if process.env.ARCJET_BASE_URL not set", () => { - expect(defaultBaseUrl()).toEqual("https://decide.arcjet.com"); - }); - - // TODO(#90): Remove these tests once production conditional is removed - test("uses process.env.ARCJET_BASE_URL if set (in development)", () => { - jest.replaceProperty(process, "env", { - NODE_ENV: "development", - ARCJET_BASE_URL: "http://localhost:1234", - }); - expect(defaultBaseUrl()).toEqual("http://localhost:1234"); - }); - - test("does not use process.env.ARCJET_BASE_URL if empty string (in development)", () => { - jest.replaceProperty(process, "env", { - NODE_ENV: "development", - ARCJET_BASE_URL: "", - }); - expect(defaultBaseUrl()).toEqual("https://decide.arcjet.com"); - }); -}); - describe("createRemoteClient", () => { - test("throws if called without a transport", () => { - expect(createRemoteClient).toThrow("Transport must be defined"); - }); + const defaultRemoteClientOptions = { + baseUrl: "", + timeout: 0, + sdkStack: "NODEJS" as const, + sdkVersion: "__ARCJET_SDK_VERSION__", + }; test("can be called with only a transport", () => { const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(() => {}), }); expect(typeof client.decide).toEqual("function"); @@ -230,6 +186,7 @@ describe("createRemoteClient", () => { test("allows overriding the default timeout", async () => { // TODO(#32): createRouterTransport doesn't seem to handle timeouts/promises correctly const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, {}); }), @@ -272,6 +229,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -327,6 +285,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -383,6 +342,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -438,6 +398,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -493,6 +454,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -552,6 +514,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -595,6 +558,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -637,6 +601,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -679,6 +644,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -730,6 +696,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -775,6 +742,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -819,6 +787,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -886,6 +855,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -953,6 +923,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -1027,6 +998,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -1094,6 +1066,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -1158,6 +1131,7 @@ describe("createRemoteClient", () => { }; const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, router); }), @@ -1242,6 +1216,7 @@ describe("createRemoteClient", () => { }); const client = createRemoteClient({ + ...defaultRemoteClientOptions, transport: createRouterTransport(({ service }) => { service(DecideService, {}); }), @@ -3226,6 +3201,7 @@ describe("SDK", () => { key: "test-key", rules: [], client, + log, }); expect(aj).toHaveProperty("protect"); expect(typeof aj.protect).toEqual("function"); @@ -3247,6 +3223,7 @@ describe("SDK", () => { key: "test-key", rules: [], client, + log, }); type WithoutRuleTest = Assert>; @@ -3282,6 +3259,7 @@ describe("SDK", () => { key: "test-key", rules: [], client, + log, }); type WithoutRuleTest = Assert>; @@ -3325,6 +3303,7 @@ describe("SDK", () => { key: "test-key", rules: [], client, + log, }); type WithoutRuleTest = Assert>; @@ -3363,6 +3342,7 @@ describe("SDK", () => { key: "test-key", rules: [[testRuleLocalAllowed(), testRuleLocalDenied()]], client, + log, }); expect(aj).toHaveProperty("protect"); expect(typeof aj.protect).toEqual("function"); @@ -3384,6 +3364,7 @@ describe("SDK", () => { key: "test-key", rules: [[testRuleRemote()]], client, + log, }); expect(aj).toHaveProperty("protect"); expect(typeof aj.protect).toEqual("function"); @@ -3407,6 +3388,7 @@ describe("SDK", () => { [testRuleLocalAllowed(), testRuleLocalDenied(), testRuleRemote()], ], client, + log, }); expect(aj).toHaveProperty("protect"); expect(typeof aj.protect).toEqual("function"); @@ -3418,6 +3400,28 @@ describe("SDK", () => { const aj = arcjet({ key: "test-key", rules: [], + log, + }); + }).toThrow(); + }); + + test("throws if no log is specified", () => { + expect(() => { + const client = { + decide: jest.fn(async () => { + return new ArcjetAllowDecision({ + ttl: 0, + reason: new ArcjetTestReason(), + results: [], + }); + }), + report: jest.fn(), + }; + + const aj = arcjet({ + key: "test-key", + rules: [], + client, }); }).toThrow(); }); @@ -3450,6 +3454,7 @@ describe("SDK", () => { key: "test-key", rules: [[allowed, denied]], client, + log, }); const decision = await aj.protect({}, request); @@ -3479,6 +3484,7 @@ describe("SDK", () => { key: "test-key", rules: [], client, + log, }); const decision = await aj.protect({}, request); @@ -3501,6 +3507,7 @@ describe("SDK", () => { key: "test-key", rules: [], client, + log, }); // @ts-expect-error @@ -3532,6 +3539,7 @@ describe("SDK", () => { key: "test-key", rules: rules, client, + log, }); const decision = await aj.protect({}, request); @@ -3566,6 +3574,7 @@ describe("SDK", () => { key: "test-key", rules: [[denied, allowed]], client, + log, }); const decision = await aj.protect({}, request); @@ -3609,6 +3618,7 @@ describe("SDK", () => { key: "test-key", rules: [], client, + log, }); const decision = await aj.protect({}, request); @@ -3662,6 +3672,7 @@ describe("SDK", () => { key: "test-key", rules: [], client, + log, }); const decision = await aj.protect({}, request); @@ -3721,6 +3732,7 @@ describe("SDK", () => { key: "test-key", rules: [], client, + log, }); const decision = await aj.protect({}, request); @@ -3772,6 +3784,7 @@ describe("SDK", () => { key: "test-key", rules: [[allowed]], client, + log, }); const _ = await aj.protect({}, request); @@ -3813,6 +3826,7 @@ describe("SDK", () => { key, rules: [[rule]], client, + log, }); const decision = await aj.protect({}, request); @@ -3867,6 +3881,7 @@ describe("SDK", () => { key, rules: [[rule]], client, + log, }); const _ = await aj.protect({}, request); @@ -3918,6 +3933,7 @@ describe("SDK", () => { key: "test-key", rules: [[denied]], client, + log, }); const _ = await aj.protect({}, request); @@ -3956,6 +3972,7 @@ describe("SDK", () => { key, rules: [], client, + log, }); const _ = await aj.protect({}, request); @@ -4005,6 +4022,7 @@ describe("SDK", () => { key: "test-key", rules: [], client, + log, }); const decision = await aj.protect({}, request); @@ -4042,6 +4060,7 @@ describe("SDK", () => { key: "test-key", rules: [[testRuleInvalidType()]], client, + log, }); }).not.toThrow("Unknown Rule type"); }); @@ -4072,6 +4091,7 @@ describe("SDK", () => { key: "test-key", rules: [[testRuleLocalThrow()]], client, + log, }); const _ = await aj.protect({}, request); @@ -4213,6 +4233,7 @@ describe("SDK", () => { key: "test-key", rules: [[testRuleLocalDryRun()]], client, + log, }); const decision = await aj.protect({}, request); @@ -4264,6 +4285,7 @@ describe("SDK", () => { key, rules: [[rule]], client, + log, }); const decision = await aj.protect({}, request); @@ -4322,6 +4344,7 @@ describe("SDK", () => { key, rules: [[rule]], client, + log, }); const decision = await aj.protect({ key: "overridden-key" }, request); @@ -4374,6 +4397,7 @@ describe("SDK", () => { key, rules: [], client, + log, }); const decision = await aj.protect({}, request); diff --git a/env/.eslintignore b/env/.eslintignore new file mode 100644 index 000000000..217248c05 --- /dev/null +++ b/env/.eslintignore @@ -0,0 +1,7 @@ +/.turbo/ +/coverage/ +/node_modules/ +*.d.ts +*.js +!test/*.js +!*.config.js diff --git a/env/.eslintrc.cjs b/env/.eslintrc.cjs new file mode 100644 index 000000000..abe4cd7b4 --- /dev/null +++ b/env/.eslintrc.cjs @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ["@arcjet/eslint-config"], +}; diff --git a/env/.gitignore b/env/.gitignore new file mode 100644 index 000000000..35b162da3 --- /dev/null +++ b/env/.gitignore @@ -0,0 +1,135 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Generated files +index.js +index.d.ts +test/*.js diff --git a/env/LICENSE b/env/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/env/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/env/README.md b/env/README.md new file mode 100644 index 000000000..10f1861f8 --- /dev/null +++ b/env/README.md @@ -0,0 +1,76 @@ + + + + Arcjet Logo + + + +# `@arcjet/env` + +

+ + + + npm badge + + +

+ +[Arcjet][arcjet] environment detection. + +Currently operates on an environment object with the type: + +```ts +type Env = { + FLY_APP_NAME?: string; + NODE_ENV?: string; + ARCJET_KEY?: string; + ARCJET_ENV?: string; + ARCJET_LOG_LEVEL?: string; + ARCJET_BASE_URL?: string; +}; +``` + +## Installation + +```shell +npm install -S @arcjet/env +``` + +## Example + +```ts +import * as env from "@arcjet/env"; + +env.platform({ FLY_APP_NAME: "foobar" }) === "fly-io"; +env.platform({}) === undefined; + +env.isProduction({ NODE_ENV: "production" }) === true; +env.isProduction({ NODE_ENV: "development" }) === false; +env.isProduction({ ARCJET_ENV: "production" }) === true; +env.isProduction({ ARCJET_ENV: "development" }) === false; + +env.isDevelopment({ NODE_ENV: "production" }) === false; +env.isDevelopment({ NODE_ENV: "development" }) === true; +env.isDevelopment({ ARCJET_ENV: "production" }) === false; +env.isDevelopment({ ARCJET_ENV: "development" }) === true; + +env.logLevel({ ARCJET_LOG_LEVEL: "debug" }) === "debug"; +env.logLevel({ ARCJET_LOG_LEVEL: "info" }) === "info"; +env.logLevel({ ARCJET_LOG_LEVEL: "warn" }) === "warn"; +env.logLevel({ ARCJET_LOG_LEVEL: "error" }) === "error"; +env.logLevel({ ARCJET_LOG_LEVEL: "" }) === "warn"; // default + +// Will use various environment variables to detect the proper base URL +env.baseUrl(process.env); + +env.apiKey({ ARCJET_KEY: "ajkey_abc123" }) === "ajkey_abc123"; +env.apiKey({ ARCJET_KEY: "invalid" }) === undefined; +``` + +## License + +Licensed under the [Apache License, Version 2.0][apache-license]. + +[arcjet]: https://arcjet.com +[apache-license]: http://www.apache.org/licenses/LICENSE-2.0 diff --git a/env/index.ts b/env/index.ts new file mode 100644 index 000000000..f15f273b6 --- /dev/null +++ b/env/index.ts @@ -0,0 +1,86 @@ +export type Env = { + [key: string]: unknown; + FLY_APP_NAME?: string; + NODE_ENV?: string; + ARCJET_KEY?: string; + ARCJET_ENV?: string; + ARCJET_LOG_LEVEL?: string; + ARCJET_BASE_URL?: string; +}; + +export function platform(env: Env) { + if (typeof env["FLY_APP_NAME"] === "string" && env["FLY_APP_NAME"] !== "") { + return "fly-io" as const; + } +} + +export function isDevelopment(env: Env) { + return env.NODE_ENV === "development" || env.ARCJET_ENV === "development"; +} + +export function isProduction(env: Env) { + return env.NODE_ENV === "production" || env.ARCJET_ENV === "production"; +} + +export function logLevel(env: Env) { + const level = env["ARCJET_LOG_LEVEL"]; + switch (level) { + case "debug": + case "info": + case "warn": + case "error": + return level; + default: + // Default to warn if not set + return "warn"; + } +} + +const baseUrlAllowed = [ + "https://decide.arcjet.com", + "https://decide.arcjettest.com", + "https://fly.decide.arcjet.com", + "https://fly.decide.arcjettest.com", + "https://decide.arcjet.orb.local:4082", +]; + +export function baseUrl(env: Env) { + // TODO(#90): Remove this production conditional before 1.0.0 + if (isProduction(env)) { + // Use ARCJET_BASE_URL if it is set and belongs to our allowlist; otherwise + // use the hardcoded default. + if ( + typeof env["ARCJET_BASE_URL"] === "string" && + baseUrlAllowed.includes(env["ARCJET_BASE_URL"]) + ) { + return env["ARCJET_BASE_URL"]; + } + + // If we're running on fly.io, use the Arcjet Decide Service hosted on fly + // Ref: https://fly.io/docs/machines/runtime-environment/#environment-variables + if (platform(env) === "fly-io") { + return "https://fly.decide.arcjet.com"; + } + + return "https://decide.arcjet.com"; + } else { + if (env["ARCJET_BASE_URL"]) { + return env["ARCJET_BASE_URL"]; + } + + // If we're running on fly.io, use the Arcjet Decide Service hosted on fly + // Ref: https://fly.io/docs/machines/runtime-environment/#environment-variables + if (platform(env) === "fly-io") { + return "https://fly.decide.arcjet.com"; + } + + return "https://decide.arcjet.com"; + } +} + +export function apiKey(env: Env) { + const key = env["ARCJET_KEY"]; + if (typeof key === "string" && key.startsWith("ajkey_")) { + return key; + } +} diff --git a/env/jest.config.js b/env/jest.config.js new file mode 100644 index 000000000..6d5656840 --- /dev/null +++ b/env/jest.config.js @@ -0,0 +1,16 @@ +/** @type {import('jest').Config} */ +const config = { + // We only test JS files once compiled with TypeScript + moduleFileExtensions: ["js"], + coverageDirectory: "coverage", + collectCoverage: true, + // If this is set to default (babel) rather than v8, tests fail with the edge + // runtime and the error "EvalError: Code generation from strings disallowed + // for this context". Tracking in + // https://github.com/vercel/edge-runtime/issues/250 + coverageProvider: "v8", + verbose: true, + testEnvironment: "node", +}; + +export default config; diff --git a/env/package.json b/env/package.json new file mode 100644 index 000000000..14ddf34a6 --- /dev/null +++ b/env/package.json @@ -0,0 +1,56 @@ +{ + "name": "@arcjet/env", + "version": "1.0.0-alpha.13", + "description": "Arcjet environment detection", + "license": "Apache-2.0", + "homepage": "https://arcjet.com", + "repository": { + "type": "git", + "url": "git+https://github.com/arcjet/arcjet-js.git", + "directory": "env" + }, + "bugs": { + "url": "https://github.com/arcjet/arcjet-js/issues", + "email": "support@arcjet.com" + }, + "author": { + "name": "Arcjet", + "email": "support@arcjet.com", + "url": "https://arcjet.com" + }, + "engines": { + "node": ">=18" + }, + "type": "module", + "main": "./index.js", + "types": "./index.d.ts", + "files": [ + "LICENSE", + "README.md", + "*.js", + "*.d.ts", + "*.ts", + "!*.config.js" + ], + "scripts": { + "prepublishOnly": "npm run build", + "build": "rollup --config rollup.config.js", + "lint": "eslint .", + "pretest": "npm run build", + "test": "NODE_OPTIONS=--experimental-vm-modules jest" + }, + "dependencies": {}, + "devDependencies": { + "@arcjet/eslint-config": "1.0.0-alpha.13", + "@arcjet/rollup-config": "1.0.0-alpha.13", + "@arcjet/tsconfig": "1.0.0-alpha.13", + "@jest/globals": "29.7.0", + "@rollup/wasm-node": "4.18.0", + "jest": "29.7.0", + "typescript": "5.4.5" + }, + "publishConfig": { + "access": "public", + "tag": "latest" + } +} diff --git a/env/rollup.config.js b/env/rollup.config.js new file mode 100644 index 000000000..79177f236 --- /dev/null +++ b/env/rollup.config.js @@ -0,0 +1,3 @@ +import { createConfig } from "@arcjet/rollup-config"; + +export default createConfig(import.meta.url); diff --git a/env/test/env.test.ts b/env/test/env.test.ts new file mode 100644 index 000000000..e90ca0728 --- /dev/null +++ b/env/test/env.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "@jest/globals"; +import * as env from "../index"; + +describe("env", () => { + test("platform", () => { + expect(env.platform({})).toBeUndefined(); + expect(env.platform({ FLY_APP_NAME: "" })).toBeUndefined(); + expect(env.platform({ FLY_APP_NAME: "foobar" })).toEqual("fly-io"); + }); + + test("isDevelopment", () => { + expect(env.isDevelopment({})).toEqual(false); + expect(env.isDevelopment({ NODE_ENV: "production" })).toEqual(false); + expect(env.isDevelopment({ NODE_ENV: "development" })).toEqual(true); + expect(env.isDevelopment({ ARCJET_ENV: "production" })).toEqual(false); + expect(env.isDevelopment({ ARCJET_ENV: "development" })).toEqual(true); + }); + + test("isProduction", () => { + expect(env.isProduction({})).toEqual(false); + expect(env.isProduction({ NODE_ENV: "production" })).toEqual(true); + expect(env.isProduction({ NODE_ENV: "development" })).toEqual(false); + expect(env.isProduction({ ARCJET_ENV: "production" })).toEqual(true); + expect(env.isProduction({ ARCJET_ENV: "development" })).toEqual(false); + }); + + test("logLevel", () => { + expect(env.logLevel({})).toEqual("warn"); + expect(env.logLevel({ ARCJET_LOG_LEVEL: "" })).toEqual("warn"); + expect(env.logLevel({ ARCJET_LOG_LEVEL: "invalid" })).toEqual("warn"); + expect(env.logLevel({ ARCJET_LOG_LEVEL: "debug" })).toEqual("debug"); + expect(env.logLevel({ ARCJET_LOG_LEVEL: "info" })).toEqual("info"); + expect(env.logLevel({ ARCJET_LOG_LEVEL: "warn" })).toEqual("warn"); + expect(env.logLevel({ ARCJET_LOG_LEVEL: "error" })).toEqual("error"); + }); + + test("baseUrl", () => { + // dev + expect(env.baseUrl({})).toEqual("https://decide.arcjet.com"); + expect(env.baseUrl({ ARCJET_BASE_URL: "anything-in-dev" })).toEqual( + "anything-in-dev", + ); + expect(env.baseUrl({ FLY_APP_NAME: "" })).toEqual( + "https://decide.arcjet.com", + ); + expect(env.baseUrl({ FLY_APP_NAME: "foobar" })).toEqual( + "https://fly.decide.arcjet.com", + ); + // prod + expect(env.baseUrl({ NODE_ENV: "production" })).toEqual( + "https://decide.arcjet.com", + ); + expect( + env.baseUrl({ + NODE_ENV: "production", + ARCJET_BASE_URL: "https://decide.arcjet.com", + }), + ).toEqual("https://decide.arcjet.com"); + expect( + env.baseUrl({ + NODE_ENV: "production", + ARCJET_BASE_URL: "https://decide.arcjettest.com", + }), + ).toEqual("https://decide.arcjettest.com"); + expect( + env.baseUrl({ + NODE_ENV: "production", + ARCJET_BASE_URL: "https://fly.decide.arcjet.com", + }), + ).toEqual("https://fly.decide.arcjet.com"); + expect( + env.baseUrl({ + NODE_ENV: "production", + ARCJET_BASE_URL: "https://fly.decide.arcjettest.com", + }), + ).toEqual("https://fly.decide.arcjettest.com"); + expect( + env.baseUrl({ + NODE_ENV: "production", + ARCJET_BASE_URL: "https://decide.arcjet.orb.local:4082", + }), + ).toEqual("https://decide.arcjet.orb.local:4082"); + expect( + env.baseUrl({ NODE_ENV: "production", FLY_APP_NAME: "foobar" }), + ).toEqual("https://fly.decide.arcjet.com"); + }); + + test("apiKey", () => { + expect(env.apiKey({})).toBeUndefined(); + expect(env.apiKey({ ARCJET_KEY: "invalid" })).toBeUndefined(); + expect(env.apiKey({ ARCJET_KEY: "ajkey_abc123" })).toEqual("ajkey_abc123"); + }); +}); diff --git a/env/tsconfig.json b/env/tsconfig.json new file mode 100644 index 000000000..7150d9377 --- /dev/null +++ b/env/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@arcjet/tsconfig/base", + "include": ["index.ts"] +} diff --git a/examples/nextjs-14-app-dir-rl/app/api/custom_timeout/route.ts b/examples/nextjs-14-app-dir-rl/app/api/custom_timeout/route.ts index 494e43890..d73e6ee66 100644 --- a/examples/nextjs-14-app-dir-rl/app/api/custom_timeout/route.ts +++ b/examples/nextjs-14-app-dir-rl/app/api/custom_timeout/route.ts @@ -1,12 +1,12 @@ import arcjet, { validateEmail, createNextRemoteClient, - defaultBaseUrl, } from "@arcjet/next"; +import { baseUrl } from "@arcjet/env"; import { NextResponse } from "next/server"; const client = createNextRemoteClient({ - baseUrl: defaultBaseUrl(), + baseUrl: baseUrl(process.env), timeout: 10, }); diff --git a/examples/nextjs-14-app-dir-rl/package-lock.json b/examples/nextjs-14-app-dir-rl/package-lock.json index ad4fb6f38..dbf254bf3 100644 --- a/examples/nextjs-14-app-dir-rl/package-lock.json +++ b/examples/nextjs-14-app-dir-rl/package-lock.json @@ -8,6 +8,7 @@ "name": "nextjs-14-app-dir-rl", "version": "0.1.0", "dependencies": { + "@arcjet/env": "file:../../env", "@arcjet/next": "file:../../arcjet-next", "next": "^14.2.3", "react": "^18", @@ -27,19 +28,22 @@ }, "../../arcjet-next": { "name": "@arcjet/next", - "version": "1.0.0-alpha.12", + "version": "1.0.0-alpha.13", "license": "Apache-2.0", "dependencies": { - "@arcjet/ip": "1.0.0-alpha.12", + "@arcjet/env": "1.0.0-alpha.13", + "@arcjet/headers": "1.0.0-alpha.13", + "@arcjet/ip": "1.0.0-alpha.13", + "@arcjet/logger": "1.0.0-alpha.13", "@connectrpc/connect-web": "1.4.0", - "arcjet": "1.0.0-alpha.12" + "arcjet": "1.0.0-alpha.13" }, "devDependencies": { - "@arcjet/eslint-config": "1.0.0-alpha.12", - "@arcjet/rollup-config": "1.0.0-alpha.12", - "@arcjet/tsconfig": "1.0.0-alpha.12", + "@arcjet/eslint-config": "1.0.0-alpha.13", + "@arcjet/rollup-config": "1.0.0-alpha.13", + "@arcjet/tsconfig": "1.0.0-alpha.13", "@jest/globals": "29.7.0", - "@rollup/wasm-node": "4.17.2", + "@rollup/wasm-node": "4.18.0", "@types/node": "18.18.0", "jest": "29.7.0", "typescript": "5.4.5" @@ -51,6 +55,22 @@ "next": ">=13" } }, + "../../env": { + "version": "1.0.0-alpha.13", + "license": "Apache-2.0", + "devDependencies": { + "@arcjet/eslint-config": "1.0.0-alpha.13", + "@arcjet/rollup-config": "1.0.0-alpha.13", + "@arcjet/tsconfig": "1.0.0-alpha.13", + "@jest/globals": "29.7.0", + "@rollup/wasm-node": "4.18.0", + "jest": "29.7.0", + "typescript": "5.4.5" + }, + "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", @@ -72,6 +92,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@arcjet/env": { + "resolved": "../../env", + "link": true + }, "node_modules/@arcjet/next": { "resolved": "../../arcjet-next", "link": true diff --git a/examples/nextjs-14-app-dir-rl/package.json b/examples/nextjs-14-app-dir-rl/package.json index cedbc765b..38afb2680 100644 --- a/examples/nextjs-14-app-dir-rl/package.json +++ b/examples/nextjs-14-app-dir-rl/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@arcjet/env": "file:../../env", "@arcjet/next": "file:../../arcjet-next", "next": "^14.2.3", "react": "^18", diff --git a/package-lock.json b/package-lock.json index 33c2db317..50a6a9edc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,8 +67,10 @@ "version": "1.0.0-alpha.13", "license": "Apache-2.0", "dependencies": { + "@arcjet/env": "1.0.0-alpha.13", "@arcjet/headers": "1.0.0-alpha.13", "@arcjet/ip": "1.0.0-alpha.13", + "@arcjet/logger": "1.0.0-alpha.13", "@connectrpc/connect-node": "1.4.0", "arcjet": "1.0.0-alpha.13" }, @@ -98,8 +100,10 @@ "version": "1.0.0-alpha.13", "license": "Apache-2.0", "dependencies": { + "@arcjet/env": "1.0.0-alpha.13", "@arcjet/headers": "1.0.0-alpha.13", "@arcjet/ip": "1.0.0-alpha.13", + "@arcjet/logger": "1.0.0-alpha.13", "@connectrpc/connect-web": "1.4.0", "arcjet": "1.0.0-alpha.13" }, @@ -125,8 +129,10 @@ "version": "1.0.0-alpha.13", "license": "Apache-2.0", "dependencies": { + "@arcjet/env": "1.0.0-alpha.13", "@arcjet/headers": "1.0.0-alpha.13", "@arcjet/ip": "1.0.0-alpha.13", + "@arcjet/logger": "1.0.0-alpha.13", "@connectrpc/connect-node": "1.4.0", "arcjet": "1.0.0-alpha.13" }, @@ -149,8 +155,10 @@ "version": "1.0.0-alpha.13", "license": "Apache-2.0", "dependencies": { + "@arcjet/env": "1.0.0-alpha.13", "@arcjet/headers": "1.0.0-alpha.13", "@arcjet/ip": "1.0.0-alpha.13", + "@arcjet/logger": "1.0.0-alpha.13", "@arcjet/runtime": "1.0.0-alpha.13", "@connectrpc/connect-node": "1.4.0", "@connectrpc/connect-web": "1.4.0", @@ -210,6 +218,23 @@ "node": ">=18" } }, + "env": { + "name": "@arcjet/env", + "version": "1.0.0-alpha.13", + "license": "Apache-2.0", + "devDependencies": { + "@arcjet/eslint-config": "1.0.0-alpha.13", + "@arcjet/rollup-config": "1.0.0-alpha.13", + "@arcjet/tsconfig": "1.0.0-alpha.13", + "@jest/globals": "29.7.0", + "@rollup/wasm-node": "4.18.0", + "jest": "29.7.0", + "typescript": "5.4.5" + }, + "engines": { + "node": ">=18" + } + }, "eslint-config": { "name": "@arcjet/eslint-config", "version": "1.0.0-alpha.13", @@ -324,6 +349,10 @@ "resolved": "duration", "link": true }, + "node_modules/@arcjet/env": { + "resolved": "env", + "link": true + }, "node_modules/@arcjet/eslint-config": { "resolved": "eslint-config", "link": true