diff --git a/apps/deploy-web/env/.env.sample b/apps/deploy-web/env/.env.sample
index 153e5fe069..4eeaa86baa 100644
--- a/apps/deploy-web/env/.env.sample
+++ b/apps/deploy-web/env/.env.sample
@@ -45,3 +45,9 @@ NEXT_PUBLIC_UNLEASH_FRONTEND_API_TOKEN=
UNLEASH_SERVER_API_URL=
UNLEASH_SERVER_API_TOKEN=
+
+NEXT_PUBLIC_TURNSTILE_ENABLED=true
+# https://developers.cloudflare.com/turnstile/troubleshooting/testing/#dummy-sitekeys-and-secret-keys
+NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
+TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
+E2E_TESTING_CLIENT_TOKEN=any-random-string
diff --git a/apps/deploy-web/playwright.config.ts b/apps/deploy-web/playwright.config.ts
index 6e701812e5..f179b66140 100644
--- a/apps/deploy-web/playwright.config.ts
+++ b/apps/deploy-web/playwright.config.ts
@@ -4,6 +4,12 @@ import path from "path";
dotenv.config({ path: path.resolve(__dirname, "env/.env.test") });
+if (!process.env.E2E_TESTING_CLIENT_TOKEN && !process.env.BASE_URL?.includes("localhost")) {
+ throw new Error(
+ "E2E_TESTING_CLIENT_TOKEN is a required env variable. Without it, tests will be blocked by captcha verification. Should be set to the same value as app's E2E_TESTING_CLIENT_TOKEN env variable."
+ );
+}
+
/**
* See https://playwright.dev/docs/test-configuration.
*/
@@ -26,7 +32,10 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "retain-on-failure",
video: "retain-on-failure",
- actionTimeout: 15_000
+ actionTimeout: 15_000,
+ extraHTTPHeaders: {
+ "X-Testing-Client-Token": process.env.E2E_TESTING_CLIENT_TOKEN || ""
+ }
},
/* Configure projects for major browsers */
diff --git a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx
index 0936692989..a4e167b9bd 100644
--- a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx
+++ b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx
@@ -1,8 +1,10 @@
+import { type RefObject, useState } from "react";
import type { Tabs } from "@akashnetwork/ui/components";
import { mock } from "jest-mock-extended";
import type { ReadonlyURLSearchParams } from "next/navigation";
import type { NextRouter } from "next/router";
+import type { TurnstileRef } from "@src/components/turnstile/Turnstile";
import type { AuthService } from "@src/services/auth/auth/auth.service";
import type { SignInForm, SignInFormValues } from "../SignInForm/SignInForm";
import type { SignUpForm, SignUpFormValues } from "../SignUpForm/SignUpForm";
@@ -110,7 +112,10 @@ describe(AuthPage.name, () => {
});
await waitFor(() => {
- expect(authService.login).toHaveBeenCalledWith(credentials);
+ expect(authService.login).toHaveBeenCalledWith({
+ ...credentials,
+ captchaToken: "test-captcha-token"
+ });
});
expect(authService.signup).not.toHaveBeenCalled();
await waitFor(() => {
@@ -168,7 +173,10 @@ describe(AuthPage.name, () => {
});
await waitFor(() => {
- expect(authService.signup).toHaveBeenCalledWith(credentials);
+ expect(authService.signup).toHaveBeenCalledWith({
+ ...credentials,
+ captchaToken: "test-captcha-token"
+ });
});
expect(authService.login).not.toHaveBeenCalled();
await waitFor(() => {
@@ -205,6 +213,48 @@ describe(AuthPage.name, () => {
});
});
+ describe("when ForgotPassword view is open", () => {
+ it("renders forgot password form when SignInForm notifies about its request", async () => {
+ const SignInFormMock = jest.fn((() => SignInForm) as typeof SignInForm);
+ const ForgotPasswordFormMock = jest.fn(() => ForgotPasswordForm);
+ const { router } = setup({
+ dependencies: {
+ SignInForm: SignInFormMock,
+ ForgotPasswordForm: ForgotPasswordFormMock
+ }
+ });
+
+ await act(() => {
+ SignInFormMock.mock.calls[0][0].onForgotPasswordClick?.();
+ });
+
+ await waitFor(() => {
+ expect(router.replace).toHaveBeenCalledTimes(1);
+ expect(screen.getByText("ForgotPasswordForm")).toBeInTheDocument();
+ });
+ });
+
+ it("submits forgot password form and displays success message", async () => {
+ const ForgotPasswordFormMock = jest.fn(ComponentMock as typeof DEPENDENCIES.ForgotPasswordForm);
+ const { authService } = setup({
+ searchParams: {
+ tab: "forgot-password"
+ },
+ dependencies: {
+ ForgotPasswordForm: ForgotPasswordFormMock
+ }
+ });
+
+ await act(() => {
+ ForgotPasswordFormMock.mock.calls[0][0].onSubmit({ email: "test@example.com" });
+ });
+
+ await waitFor(() => {
+ expect(authService.sendPasswordResetEmail).toHaveBeenCalledWith({ email: "test@example.com", captchaToken: "test-captcha-token" });
+ });
+ });
+ });
+
function setup(input: {
searchParams?: {
tab?: "login" | "signup" | "forgot-password";
@@ -214,7 +264,13 @@ describe(AuthPage.name, () => {
dependencies?: Partial;
}) {
const authService = mock();
- const router = mock();
+ let setRouterPageParams: (params: URLSearchParams) => void = () => {};
+ const router = mock({
+ replace: jest.fn(url => {
+ setRouterPageParams?.(new URL(url as string, "http://localunittest:8080").searchParams);
+ return Promise.resolve(true);
+ })
+ });
const checkSession = jest.fn(async () => undefined);
const useUserMock: typeof DEPENDENCIES.useUser = () => ({
checkSession,
@@ -231,7 +287,20 @@ describe(AuthPage.name, () => {
if (input.searchParams?.returnTo) {
params.set("returnTo", input.searchParams.returnTo);
}
- const useSearchParamsMock = () => params as ReadonlyURLSearchParams;
+ const useSearchParamsMock = () => {
+ const [pageParams, setPageParams] = useState(params);
+ setRouterPageParams = setPageParams;
+ return pageParams as ReadonlyURLSearchParams;
+ };
+
+ const TurnstileMock = jest.fn(({ turnstileRef }: { turnstileRef?: RefObject }) => {
+ if (turnstileRef) {
+ (turnstileRef as { current: TurnstileRef }).current = {
+ renderAndWaitResponse: jest.fn().mockResolvedValue({ token: "test-captcha-token" })
+ };
+ }
+ return null;
+ });
render(
authService }}>
@@ -241,6 +310,7 @@ describe(AuthPage.name, () => {
useUser: useUserMock,
useSearchParams: useSearchParamsMock,
useRouter: () => router,
+ Turnstile: TurnstileMock,
...input.dependencies
}}
/>
diff --git a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx
index 57d1832f12..069aa366e6 100644
--- a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx
+++ b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useState } from "react";
+import { useCallback, useRef, useState } from "react";
import { Separator, Tabs, TabsContent, TabsList, TabsTrigger } from "@akashnetwork/ui/components";
import { useMutation } from "@tanstack/react-query";
import { DollarSignIcon, RocketIcon, ZapIcon } from "lucide-react";
@@ -10,6 +10,8 @@ import { NextSeo } from "next-seo";
import { AkashConsoleLogo } from "@src/components/icons/AkashConsoleLogo";
import { RemoteApiError } from "@src/components/shared/RemoteApiError/RemoteApiError";
+import type { TurnstileRef } from "@src/components/turnstile/Turnstile";
+import { ClientOnlyTurnstile } from "@src/components/turnstile/Turnstile";
import { useServices } from "@src/context/ServicesProvider";
import { useUser } from "@src/hooks/useUser";
import { AuthLayout } from "../AuthLayout/AuthLayout";
@@ -28,10 +30,16 @@ export const DEPENDENCIES = {
SignUpForm,
RemoteApiError,
ForgotPasswordForm,
+ Turnstile: ClientOnlyTurnstile,
Tabs,
TabsContent,
TabsTrigger,
TabsList,
+ DollarSignIcon,
+ RocketIcon,
+ ZapIcon,
+ AkashConsoleLogo,
+ Separator,
useUser,
useSearchParams,
useRouter
@@ -42,11 +50,12 @@ interface Props {
}
export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
- const { authService } = useServices();
+ const { authService, publicConfig } = useServices();
const router = d.useRouter();
const searchParams = d.useSearchParams();
const { checkSession } = d.useUser();
const [email, setEmail] = useState("");
+ const turnstileRef = useRef(null);
const redirectToSocialLogin = useCallback(
async (provider: "github" | "google-oauth2") => {
@@ -57,11 +66,17 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
);
const signInOrSignUp = useMutation({
async mutationFn(input: Tagged<"signin", SignInFormValues> | Tagged<"signup", SignUpFormValues>) {
+ if (!turnstileRef.current) {
+ throw new Error("Captcha has not been rendered");
+ }
const returnUrl = searchParams.get("from") || searchParams.get("returnTo") || "/";
+
+ const { token: captchaToken } = await turnstileRef.current.renderAndWaitResponse();
+
if (input.type === "signin") {
- await authService.login(input.value);
+ await authService.login({ ...input.value, captchaToken });
} else {
- await authService.signup(input.value);
+ await authService.signup({ ...input.value, captchaToken });
}
await checkSession();
router.push(returnUrl);
@@ -74,17 +89,24 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
const tabId = value !== "login" && value !== "signup" && value !== "forgot-password" ? "login" : value;
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set("tab", tabId);
- signInOrSignUp.reset();
- forgotPassword.reset();
+ resetMutations();
router.replace(`?${newSearchParams.toString()}`, undefined, { shallow: true });
},
[searchParams, router]
);
const forgotPassword = useMutation({
async mutationFn(input: { email: string }) {
- await authService.sendPasswordResetEmail({ email: input.email });
+ if (!turnstileRef.current) {
+ throw new Error("Captcha has not been rendered");
+ }
+ const { token: captchaToken } = await turnstileRef.current.renderAndWaitResponse();
+ await authService.sendPasswordResetEmail({ email: input.email, captchaToken });
}
});
+ const resetMutations = useCallback(() => {
+ signInOrSignUp.reset();
+ forgotPassword.reset();
+ }, [signInOrSignUp, forgotPassword]);
return (
The fastest way to deploy an application on Akash.Network
-
+
Generous Free Trial
@@ -103,7 +125,7 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
-
+
Optimized for AI/ML
@@ -112,7 +134,7 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
-
+
Cost Savings
@@ -126,7 +148,7 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
-
+
{(activeView === "forgot-password" && "Reset your password") || (
@@ -142,9 +164,10 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
-
+
{(activeView === "forgot-password" && (
<>
+ test me here?
-
+
Or continue with
@@ -210,6 +233,12 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
)}
+
>
diff --git a/apps/deploy-web/src/components/auth/SignInForm/SignInForm.tsx b/apps/deploy-web/src/components/auth/SignInForm/SignInForm.tsx
index c176b5b1b2..f00e2639ce 100644
--- a/apps/deploy-web/src/components/auth/SignInForm/SignInForm.tsx
+++ b/apps/deploy-web/src/components/auth/SignInForm/SignInForm.tsx
@@ -87,7 +87,13 @@ export function SignInForm({ dependencies: d = DEPENDENCIES, ...props }: Props)
<>
Password
-
+
Forgot password?
diff --git a/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.spec.tsx b/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.spec.tsx
index 930e73cca2..f5f097b79c 100644
--- a/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.spec.tsx
+++ b/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.spec.tsx
@@ -115,8 +115,8 @@ describe(TrendIndicator.name, () => {
function setup(props: TrendIndicatorProps<"totalUsdSpent", UsageHistory>) {
const defaultProps: TrendIndicatorProps<"totalUsdSpent", UsageHistory> = {
components: {
- GraphUp: () =>
Graph Up
,
- GraphDown: () =>
Graph Down
+ GraphUp: (() =>
Graph Up) as unknown as Required
>["components"]["GraphUp"],
+ GraphDown: (() => Graph Down) as unknown as Required>["components"]["GraphDown"]
},
isFetching: props.isFetching ?? false,
data: props.data ?? [
diff --git a/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx b/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx
index 9249522cc0..38c0608ca3 100644
--- a/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx
+++ b/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx
@@ -5,6 +5,7 @@ import { type TurnstileProps } from "@marsidev/react-turnstile";
import { mock } from "jest-mock-extended";
import { setTimeout as wait } from "node:timers/promises";
+import type { TurnstileRef } from "./Turnstile";
import { COMPONENTS, Turnstile } from "./Turnstile";
import { fireEvent, render, screen } from "@testing-library/react";
@@ -17,20 +18,6 @@ describe(Turnstile.name, () => {
expect(screen.queryByText("Turnstile")).not.toBeInTheDocument();
});
- it("does not patch fetch API if turnstile is disabled", async () => {
- const originalFetch = window.fetch;
- await setup({ enabled: false });
-
- expect(window.fetch).toBe(originalFetch);
- });
-
- it("patches fetch API if turnstile is enabled", async () => {
- const originalFetch = window.fetch;
- await setup({ enabled: true });
-
- expect(window.fetch).not.toBe(originalFetch);
- });
-
it("renders turnstile widget", async () => {
await setup({ enabled: true });
@@ -72,22 +59,24 @@ describe(Turnstile.name, () => {
))
}
});
- fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ fireEvent.click(screen.getAllByRole("button")[0]);
expect(turnstileInstance.remove).toHaveBeenCalled();
expect(turnstileInstance.render).toHaveBeenCalled();
expect(turnstileInstance.execute).toHaveBeenCalled();
});
- it('removes actual widget on "Dismiss" button click', async () => {
+ it('removes actual widget on "Go Back" button click', async () => {
const turnstileInstance = mock();
const ReactTurnstile = forwardRef((props, ref) => {
useForwardedRef(ref, turnstileInstance);
return Turnstile
;
});
+ const onDismissed = jest.fn();
await setup({
enabled: true,
+ onDismissed,
components: {
ReactTurnstile,
Button: forwardRef((props, ref) => (
@@ -97,95 +86,81 @@ describe(Turnstile.name, () => {
))
}
});
- fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
+ fireEvent.click(screen.getAllByRole("button")[1]);
expect(turnstileInstance.remove).toHaveBeenCalled();
expect(turnstileInstance.render).not.toHaveBeenCalled();
expect(turnstileInstance.execute).not.toHaveBeenCalled();
+ expect(onDismissed).toHaveBeenCalled();
});
- describe("when CF-Mitigated header is present", () => {
- let originalFetch: typeof globalThis.fetch;
-
- beforeEach(() => {
- originalFetch = globalThis.fetch;
- let amountOfCalls = 0;
- globalThis.fetch = jest.fn(async () => {
- if (amountOfCalls > 0) {
- return new Response("done", {
- status: 200
- });
- }
-
- const response = new Response("", {
- status: 403,
- headers: new Headers({ "cf-mitigated": "challenge" })
- });
+ describe("renderAndWaitResponse", () => {
+ it("resolves with token when challenge is solved", async () => {
+ const turnstileInstance = mock();
+ let triggerSuccess: ((token: string) => void) | undefined;
+ const ReactTurnstile = forwardRef((props, ref) => {
+ useForwardedRef(ref, turnstileInstance);
+ triggerSuccess = (token: string) => props.onSuccess?.(token);
+ return Turnstile
;
+ });
- amountOfCalls++;
+ const { turnstileRef } = await setup({
+ enabled: true,
+ components: { ReactTurnstile }
+ });
- return response;
+ const promise = turnstileRef.current!.renderAndWaitResponse();
+ await act(async () => {
+ triggerSuccess?.("test-token");
+ await wait(0);
});
- });
- afterEach(() => {
- globalThis.fetch = originalFetch;
+ await expect(promise).resolves.toEqual({ token: "test-token" });
+ expect(turnstileInstance.remove).toHaveBeenCalled();
+ expect(turnstileInstance.render).toHaveBeenCalled();
+ expect(turnstileInstance.execute).toHaveBeenCalled();
});
- it("renders turnstile widget", async () => {
+ it("rejects with error when challenge fails", async () => {
const turnstileInstance = mock();
+ let triggerError: ((error: string) => void) | undefined;
const ReactTurnstile = forwardRef((props, ref) => {
useForwardedRef(ref, turnstileInstance);
+ triggerError = (error: string) => props.onError?.(error);
return Turnstile
;
});
- await setup({ enabled: true, components: { ReactTurnstile } });
- await fetch("/");
-
- expect(turnstileInstance.render).toHaveBeenCalled();
- });
-
- it('does not retry request if "Dismiss" button is clicked', async () => {
- const fetchMock = globalThis.fetch;
-
- await setup({
+ const { turnstileRef } = await setup({
enabled: true,
- components: {
- Button: forwardRef((props, ref) => (
-
- ))
- }
+ components: { ReactTurnstile }
});
- await fetch("/");
- fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
- expect(fetchMock).toHaveBeenCalledTimes(1);
- });
-
- it("retries request if challenge is solved", async () => {
- const fetchMock = globalThis.fetch;
- const turnstileInstance = mock({
- getResponsePromise: () => Promise.resolve("test response")
+ let rejection: unknown;
+ const promise = turnstileRef.current!.renderAndWaitResponse().catch(error => {
+ rejection = error;
});
- const ReactTurnstile = forwardRef((props, ref) => {
- useForwardedRef(ref, turnstileInstance);
- return Turnstile
;
+ await act(async () => {
+ triggerError?.("test-error");
+ await wait(0);
});
+ await promise;
- await setup({ enabled: true, components: { ReactTurnstile } });
- await fetch("/");
-
- expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(rejection).toMatchObject({
+ reason: "error",
+ error: "test-error"
+ });
});
});
- async function setup(input?: { enabled?: boolean; siteKey?: string; components?: Partial }) {
+ async function setup(input?: { enabled?: boolean; siteKey?: string; onDismissed?: () => void; components?: Partial }) {
+ const turnstileRef = { current: null as TurnstileRef | null };
+
const result = render(
((_, ref) => {
useForwardedRef(ref);
@@ -197,7 +172,7 @@ describe(Turnstile.name, () => {
);
await act(() => wait(0));
- return result;
+ return { ...result, turnstileRef };
}
function useForwardedRef(ref: React.ForwardedRef, instance: T = mock()) {
diff --git a/apps/deploy-web/src/components/turnstile/Turnstile.tsx b/apps/deploy-web/src/components/turnstile/Turnstile.tsx
index 6208570ef1..b4b6d4ac25 100644
--- a/apps/deploy-web/src/components/turnstile/Turnstile.tsx
+++ b/apps/deploy-web/src/components/turnstile/Turnstile.tsx
@@ -1,17 +1,17 @@
"use client";
-import type { FC } from "react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { RefObject } from "react";
+import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from "react";
import { MdInfo } from "react-icons/md";
import { Button } from "@akashnetwork/ui/components";
import type { TurnstileInstance } from "@marsidev/react-turnstile";
import { Turnstile as ReactTurnstile } from "@marsidev/react-turnstile";
import { motion } from "framer-motion";
+import { RefreshCwIcon, Undo2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useWhen } from "@src/hooks/useWhen";
-
-let originalFetch: typeof fetch | undefined;
+import { getInjectedConfig } from "@src/utils/getInjectedConfig/getInjectedConfig";
type TurnstileStatus = "uninitialized" | "solved" | "interactive" | "expired" | "error" | "dismissed";
@@ -23,68 +23,71 @@ export const COMPONENTS = {
MdInfo
};
+export type TurnstileRef = {
+ renderAndWaitResponse: () => Promise<{ token: string }>;
+};
+
type TurnstileProps = {
enabled: boolean;
siteKey: string;
+ onDismissed?: () => void;
+ turnstileRef?: RefObject;
components?: typeof COMPONENTS;
};
-export const Turnstile: FC = ({ enabled, siteKey, components: c = COMPONENTS }) => {
+export const Turnstile = forwardRef(function Turnstile(
+ { enabled, siteKey, onDismissed, turnstileRef: externalTurnstileRef, components: c = COMPONENTS },
+ ref
+) {
const turnstileRef = useRef();
const [status, setStatus] = useState("uninitialized");
const isVisible = useMemo(() => enabled && VISIBILITY_STATUSES.includes(status), [enabled, status]);
- const abortControllerRef = useRef();
+ const eventBus = useRef(new EventTarget());
+ const injectedConfig = getInjectedConfig();
const resetWidget = useCallback(() => {
turnstileRef.current?.remove();
turnstileRef.current?.render();
turnstileRef.current?.execute();
}, []);
+ const hideWidget = useCallback(() => {
+ setStatus("dismissed");
+ onDismissed?.();
+ }, [onDismissed]);
- const renderTurnstileAndWaitForResponse = useCallback(async () => {
- abortControllerRef.current = new AbortController();
+ useWhen(status === "error" || status === "expired", () => {
resetWidget();
+ });
+ useWhen(status === "dismissed", () => {
+ turnstileRef.current?.remove();
+ });
- return Promise.race([
- turnstileRef.current?.getResponsePromise(),
- new Promise(resolve => abortControllerRef.current?.signal.addEventListener("abort", () => resolve()))
- ]);
- }, [resetWidget]);
-
- useEffect(() => {
- if (!enabled) {
- if (typeof originalFetch === "function") {
- globalThis.fetch = originalFetch;
- originalFetch = undefined;
- }
- return;
- }
-
- if (typeof globalThis.fetch === "function") {
- originalFetch = originalFetch || globalThis.fetch;
- const fetch = originalFetch;
- globalThis.fetch = async (resource, options) => {
- const response = await fetch(resource, options);
-
- if (response.headers.get("cf-mitigated") === "challenge" && turnstileRef.current && (await renderTurnstileAndWaitForResponse())) {
- return globalThis.fetch(resource, options);
- }
-
- return response;
- };
- }
-
- return () => {
- if (typeof originalFetch === "function") {
- globalThis.fetch = originalFetch;
- originalFetch = undefined;
+ useImperativeHandle(
+ ref || externalTurnstileRef,
+ () => ({
+ renderAndWaitResponse() {
+ resetWidget();
+ return new Promise((resolve, reject) => {
+ eventBus.current.addEventListener(
+ "success",
+ event => {
+ resolve((event as CustomEvent<{ token: string }>).detail);
+ },
+ { once: true }
+ );
+ eventBus.current.addEventListener(
+ "error",
+ event => {
+ const details = (event as CustomEvent<{ reason: string; error?: string }>).detail;
+ reject({ status, ...details });
+ },
+ { once: true }
+ );
+ });
}
- };
- }, [enabled]);
-
- useWhen(status === "error", () => {
- resetWidget();
- });
+ }),
+ [resetWidget]
+ );
if (!enabled) {
return null;
@@ -93,7 +96,7 @@ export const Turnstile: FC = ({ enabled, siteKey, components: c
return (
<>
= ({ enabled, siteKey, components: c
delay: isVisible ? 0 : status === "dismissed" ? 0 : 1
}}
>
-
-
We are verifying you are a human. This may take a few seconds
-
Reviewing the security of your connection before proceeding
-
+
+
+
We are verifying you are a human. This may take a moment
+
Reviewing the security of your connection before proceeding
+
+
setStatus("error")}
- onExpire={() => setStatus("expired")}
- onSuccess={() => setStatus("solved")}
+ onError={error => {
+ setStatus("error");
+ eventBus.current.dispatchEvent(new CustomEvent("error", { detail: { error, reason: "error" } }));
+ }}
+ onExpire={() => {
+ setStatus("expired");
+ eventBus.current.dispatchEvent(new CustomEvent("expired", { detail: { reason: "expired" } }));
+ }}
+ onSuccess={token => {
+ setStatus("solved");
+ eventBus.current.dispatchEvent(new CustomEvent("success", { detail: { token } }));
+ }}
onBeforeInteractive={() => setStatus("interactive")}
/>
+
+
+
+
+
+
+
+
+
+
{status === "error" &&
Some error occurred
}
-
-
-
- Retry
-
- {
- setStatus("dismissed");
- abortControllerRef.current?.abort();
- turnstileRef.current?.remove();
- }}
- variant="link"
- >
- Dismiss
-
-
-
-
-
- dismissing the check might result into some features not working properly
-
-
>
);
-};
+});
-export const ClientOnlyTurnstile = dynamic(() => Promise.resolve(Turnstile), { ssr: false });
+export const ClientOnlyTurnstile = dynamic(async () => Turnstile, { ssr: false });
diff --git a/apps/deploy-web/src/config/env-config.schema.ts b/apps/deploy-web/src/config/env-config.schema.ts
index 36242f235b..b320fbb902 100644
--- a/apps/deploy-web/src/config/env-config.schema.ts
+++ b/apps/deploy-web/src/config/env-config.schema.ts
@@ -64,7 +64,15 @@ export const serverEnvSchema = browserEnvSchema.extend({
NEXT_PUBLIC_UNLEASH_ENABLE_ALL: coercedBoolean().optional().default("false"),
NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID: networkId.optional().default("mainnet"),
NEXT_PUBLIC_DEFAULT_NETWORK_ID: networkId.optional().default("mainnet"),
- NODE_ENV: z.enum(["development", "production", "test"]).optional().default("development")
+ NODE_ENV: z.enum(["development", "production", "test"]).optional().default("development"),
+ TURNSTILE_SECRET_KEY: z.string(),
+ /**
+ * @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/#test-secret-keys
+ */
+ TURNSTILE_BYPASS_SECRET_KEY: z.string().default("1x0000000000000000000000000000000AA"),
+ E2E_TESTING_CLIENT_TOKEN: z.string({
+ required_error: "This token is used to adjust configuration of the app for e2e testing. Can be any random string."
+ })
});
export type BrowserEnvConfig = z.infer
;
diff --git a/apps/deploy-web/src/hooks/useInjectedConfig.spec.ts b/apps/deploy-web/src/hooks/useInjectedConfig.spec.ts
deleted file mode 100644
index e9d4231063..0000000000
--- a/apps/deploy-web/src/hooks/useInjectedConfig.spec.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { setTimeout as wait } from "node:timers/promises";
-
-import { useInjectedConfig } from "./useInjectedConfig";
-
-import { act, renderHook } from "@testing-library/react";
-
-describe(useInjectedConfig.name, () => {
- it("returns null config and isLoaded=true when no injected config exists", () => {
- const hasConfig = jest.fn().mockReturnValue(false);
- const decodeConfig = jest.fn().mockResolvedValue(null);
- const { result } = renderHook(() =>
- useInjectedConfig({
- hasConfig,
- decodeConfig
- })
- );
-
- expect(result.current).toEqual({ config: null, isLoaded: true });
- expect(decodeConfig).not.toHaveBeenCalled();
- });
-
- it("returns decoded config and isLoaded=true when injected config exists", async () => {
- const mockConfig = { NEXT_PUBLIC_TURNSTILE_ENABLED: true };
- const { result } = renderHook(() =>
- useInjectedConfig({
- hasConfig: () => true,
- decodeConfig: () => Promise.resolve(mockConfig)
- })
- );
- expect(result.current).toEqual({ config: null, isLoaded: false });
-
- await act(() => wait(0));
-
- expect(result.current).toEqual({ config: mockConfig, isLoaded: true });
- });
-});
diff --git a/apps/deploy-web/src/hooks/useInjectedConfig.ts b/apps/deploy-web/src/hooks/useInjectedConfig.ts
deleted file mode 100644
index 661738b238..0000000000
--- a/apps/deploy-web/src/hooks/useInjectedConfig.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { useEffect, useState } from "react";
-
-import type { BrowserEnvConfig } from "@src/config/env-config.schema";
-import { decodeInjectedConfig, hasInjectedConfig } from "@src/services/decodeInjectedConfig/decodeInjectedConfig";
-
-/**
- * This hook is used to get the injected and verified config from the window object.
- */
-export function useInjectedConfig({
- decodeConfig = decodeInjectedConfig,
- hasConfig = hasInjectedConfig
-}: InjectedConfigHookProps = {}): InjectedConfigHookResult {
- const [isLoaded, setIsLoaded] = useState(false);
- const [config, setConfig] = useState | null>(null);
-
- useEffect(() => {
- if (hasConfig()) {
- decodeConfig()
- .then(setConfig)
- .finally(() => setIsLoaded(true));
- } else {
- setIsLoaded(true);
- }
- }, []);
-
- return { config, isLoaded };
-}
-
-/**
- * Cannot use DI in this hook directly because we need to use it at the very beginning of the app
- */
-export interface InjectedConfigHookProps {
- decodeConfig?: typeof decodeInjectedConfig;
- hasConfig?: typeof hasInjectedConfig;
-}
-
-export interface InjectedConfigHookResult {
- config: Partial | null;
- isLoaded: boolean;
-}
diff --git a/apps/deploy-web/src/lib/nextjs/defineApiHandler/defineApiHandler.ts b/apps/deploy-web/src/lib/nextjs/defineApiHandler/defineApiHandler.ts
index fede5c542b..0f7a84160d 100644
--- a/apps/deploy-web/src/lib/nextjs/defineApiHandler/defineApiHandler.ts
+++ b/apps/deploy-web/src/lib/nextjs/defineApiHandler/defineApiHandler.ts
@@ -15,6 +15,10 @@ export function defineApiHandler | u
): NextApiHandler {
return wrapApiHandlerWithSentry(
(async (req, res) => {
+ if (options.method && req.method !== options.method) {
+ return res.status(405).json({ message: "Method not allowed" } as TResponse);
+ }
+
const requestServices = (req as NextApiRequestWithServices)[REQ_SERVICES_KEY] || services;
const session = await requestServices.getSession(req, res);
@@ -57,6 +61,7 @@ export interface ApiHandlerOptions |
* The parametrized route of the API handler.
*/
route: string;
+ method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS" | "HEAD";
schema?: TSchema;
handler(context: ApiHandlerContext): Promise | void;
}
diff --git a/apps/deploy-web/src/lib/nextjs/pages/api/proxy/path.spec.ts b/apps/deploy-web/src/lib/nextjs/pages/api/proxy/path.spec.ts
index 623e8d7e56..4381a82e14 100644
--- a/apps/deploy-web/src/lib/nextjs/pages/api/proxy/path.spec.ts
+++ b/apps/deploy-web/src/lib/nextjs/pages/api/proxy/path.spec.ts
@@ -30,7 +30,7 @@ describe("proxy API handler", () => {
const mockGetSession = jest.fn();
const mockUserTracker = mock();
const mockApiUrlService = mock();
- const mockConfig = mock();
+ const mockConfig = mock();
const mockProxy = {
once: jest.fn().mockReturnThis(),
@@ -74,9 +74,9 @@ describe("proxy API handler", () => {
getSession: mockGetSession,
httpProxy: mockHttpProxy,
apiUrlService: mockApiUrlService,
- config: mockConfig,
+ privateConfig: mockConfig,
userTracker: mockUserTracker
- } as unknown as typeof services;
+ } satisfies typeof services;
const handlerPromise = proxyHandler(req, res);
diff --git a/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.spec.ts b/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.spec.ts
new file mode 100644
index 0000000000..f92971ba62
--- /dev/null
+++ b/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.spec.ts
@@ -0,0 +1,186 @@
+import type { LoggerService } from "@akashnetwork/logging";
+import { mock } from "jest-mock-extended";
+import type { GetServerSidePropsContext } from "next";
+import { Err, Ok } from "ts-results";
+
+import type { AppTypedContext } from "@src/lib/nextjs/defineServerSideProps/defineServerSideProps";
+import type { TurnstileVerifierService } from "@src/services/turnstile-verifier/turnstile-verifier.service";
+import { verifyCaptcha } from "./verify-captcha";
+
+describe(verifyCaptcha.name, () => {
+ it("returns Ok when turnstile is disabled", async () => {
+ const { result } = await setup({
+ turnstileEnabled: false,
+ captchaToken: "test-token"
+ });
+
+ expect(result.ok).toBe(true);
+ });
+
+ it("returns Ok when captcha verification succeeds", async () => {
+ const { result, captchaVerifier } = await setup({
+ turnstileEnabled: true,
+ captchaToken: "valid-token",
+ verificationResult: Ok(undefined)
+ });
+
+ expect(result.ok).toBe(true);
+ expect(captchaVerifier.verify).toHaveBeenCalledWith("valid-token", {
+ remoteIp: "127.0.0.1",
+ bypassVerificationToken: undefined
+ });
+ });
+
+ it("returns Err when captcha verification fails", async () => {
+ const { result, logger } = await setup({
+ turnstileEnabled: true,
+ captchaToken: "invalid-token",
+ verificationResult: Err({
+ code: "verification_failed" as const,
+ errorCodes: ["invalid-input-response" as const]
+ })
+ });
+
+ expect(result.err).toBe(true);
+ if (result.err) {
+ expect(result.val).toEqual({
+ code: "captcha_verification_failed",
+ message: "Captcha verification failed. Please try again."
+ });
+ }
+ expect(logger.warn).toHaveBeenCalledWith({
+ event: "CAPTCHA_VERIFICATION_FAILED",
+ cause: {
+ code: "verification_failed",
+ errorCodes: ["invalid-input-response"]
+ }
+ });
+ });
+
+ it("passes bypass verification token from headers", async () => {
+ const { captchaVerifier } = await setup({
+ turnstileEnabled: true,
+ captchaToken: "test-token",
+ verificationResult: Ok(undefined),
+ headers: {
+ "x-testing-client-token": "bypass-token"
+ }
+ });
+
+ expect(captchaVerifier.verify).toHaveBeenCalledWith("test-token", {
+ remoteIp: "127.0.0.1",
+ bypassVerificationToken: "bypass-token"
+ });
+ });
+
+ describe("when extracting remote IP", () => {
+ it("uses cf-connecting-ip header when available", async () => {
+ const { captchaVerifier } = await setup({
+ turnstileEnabled: true,
+ captchaToken: "test-token",
+ verificationResult: Ok(undefined),
+ headers: {
+ "cf-connecting-ip": "1.2.3.4",
+ "x-real-ip": "5.6.7.8",
+ "x-forwarded-for": "9.10.11.12"
+ }
+ });
+
+ expect(captchaVerifier.verify).toHaveBeenCalledWith("test-token", {
+ remoteIp: "1.2.3.4",
+ bypassVerificationToken: undefined
+ });
+ });
+
+ it("uses x-real-ip header when cf-connecting-ip is not available", async () => {
+ const { captchaVerifier } = await setup({
+ turnstileEnabled: true,
+ captchaToken: "test-token",
+ verificationResult: Ok(undefined),
+ headers: {
+ "x-real-ip": "5.6.7.8",
+ "x-forwarded-for": "9.10.11.12"
+ }
+ });
+
+ expect(captchaVerifier.verify).toHaveBeenCalledWith("test-token", {
+ remoteIp: "5.6.7.8",
+ bypassVerificationToken: undefined
+ });
+ });
+
+ it("uses first IP from x-forwarded-for header when other headers are not available", async () => {
+ const { captchaVerifier } = await setup({
+ turnstileEnabled: true,
+ captchaToken: "test-token",
+ verificationResult: Ok(undefined),
+ headers: {
+ "x-forwarded-for": "9.10.11.12, 13.14.15.16"
+ }
+ });
+
+ expect(captchaVerifier.verify).toHaveBeenCalledWith("test-token", {
+ remoteIp: "9.10.11.12",
+ bypassVerificationToken: undefined
+ });
+ });
+
+ it("uses socket remoteAddress when no headers are available", async () => {
+ const { captchaVerifier } = await setup({
+ turnstileEnabled: true,
+ captchaToken: "test-token",
+ verificationResult: Ok(undefined),
+ headers: {},
+ socketRemoteAddress: "192.168.1.1"
+ });
+
+ expect(captchaVerifier.verify).toHaveBeenCalledWith("test-token", {
+ remoteIp: "192.168.1.1",
+ bypassVerificationToken: undefined
+ });
+ });
+ });
+
+ async function setup(input: {
+ turnstileEnabled: boolean;
+ captchaToken: string;
+ verificationResult?: Awaited>;
+ headers?: Record;
+ socketRemoteAddress?: string;
+ }) {
+ const captchaVerifier = mock();
+ const logger = mock();
+
+ captchaVerifier.verify.mockResolvedValue(input.verificationResult ?? Ok(undefined));
+
+ const headers = input.headers ?? {
+ "x-forwarded-for": "127.0.0.1"
+ };
+ const req = {
+ headers,
+ socket: {
+ remoteAddress: input.socketRemoteAddress ?? "127.0.0.1"
+ }
+ } as GetServerSidePropsContext["req"];
+
+ const context = {
+ req,
+ services: mock({
+ publicConfig: {
+ NEXT_PUBLIC_TURNSTILE_ENABLED: input.turnstileEnabled
+ } as AppTypedContext["services"]["publicConfig"],
+ captchaVerifier,
+ logger
+ })
+ };
+
+ const result = await verifyCaptcha(input.captchaToken, context);
+
+ return {
+ result,
+ captchaVerifier,
+ logger,
+ req
+ };
+ }
+});
diff --git a/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.ts b/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.ts
new file mode 100644
index 0000000000..372d3dfe08
--- /dev/null
+++ b/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.ts
@@ -0,0 +1,36 @@
+import type { IncomingMessage } from "http";
+import { Err, Ok } from "ts-results";
+
+import type { AppTypedContext } from "@src/lib/nextjs/defineServerSideProps/defineServerSideProps";
+
+export async function verifyCaptcha(captchaToken: string, { services, req }: Pick) {
+ if (!services.publicConfig.NEXT_PUBLIC_TURNSTILE_ENABLED) {
+ return Ok(undefined);
+ }
+
+ const remoteIp = getRemoteIp(req);
+ const captchaVerification = await services.captchaVerifier.verify(captchaToken, {
+ remoteIp,
+ bypassVerificationToken: req.headers["x-testing-client-token"] as string | undefined
+ });
+ if (captchaVerification.err) {
+ services.logger.warn({
+ event: "CAPTCHA_VERIFICATION_FAILED",
+ cause: captchaVerification.val
+ });
+
+ return Err({
+ code: "captcha_verification_failed",
+ message: "Captcha verification failed. Please try again."
+ });
+ }
+
+ return Ok(undefined);
+}
+
+function getRemoteIp(req: IncomingMessage) {
+ if (req.headers["cf-connecting-ip"]) return req.headers["cf-connecting-ip"] as string;
+ if (req.headers["x-real-ip"]) return req.headers["x-real-ip"] as string;
+ if (typeof req.headers["x-forwarded-for"] === "string") return req.headers["x-forwarded-for"].split(",")[0]?.trim();
+ return req.socket?.remoteAddress;
+}
diff --git a/apps/deploy-web/src/pages/_app.tsx b/apps/deploy-web/src/pages/_app.tsx
index 9142431406..cb4bf9cbbd 100644
--- a/apps/deploy-web/src/pages/_app.tsx
+++ b/apps/deploy-web/src/pages/_app.tsx
@@ -19,11 +19,8 @@ import NProgress from "nprogress";
import GoogleAnalytics from "@src/components/layout/CustomGoogleAnalytics";
import { CustomIntlProvider } from "@src/components/layout/CustomIntlProvider";
-import { Loading } from "@src/components/layout/Layout";
import { PageHead } from "@src/components/layout/PageHead";
-import { ClientOnlyTurnstile } from "@src/components/turnstile/Turnstile";
import { UserProviders } from "@src/components/user/UserProviders/UserProviders";
-import { browserEnvConfig } from "@src/config/browser-env.config";
import { CertificateProvider } from "@src/context/CertificateProvider";
import { CustomChainProvider } from "@src/context/CustomChainProvider";
import { ColorModeProvider } from "@src/context/CustomThemeContext";
@@ -34,7 +31,6 @@ import { ServicesProvider } from "@src/context/ServicesProvider";
import { RootContainerProvider, useRootContainer } from "@src/context/ServicesProvider/RootContainerProvider";
import { SettingsProvider } from "@src/context/SettingsProvider";
import { WalletProvider } from "@src/context/WalletProvider";
-import { useInjectedConfig } from "@src/hooks/useInjectedConfig";
import { store } from "@src/store/global-store";
interface Props extends AppProps {
@@ -52,24 +48,10 @@ Router.events.on("routeChangeError", () => NProgress.done());
const App: React.FunctionComponent = props => {
const { Component, pageProps } = props;
- const { config, isLoaded: isLoadedInjectedConfig } = useInjectedConfig();
-
- if (!isLoadedInjectedConfig) {
- return (
-
-
-
- );
- }
return (
<>
-
-
diff --git a/apps/deploy-web/src/pages/api/auth/[...auth0].ts b/apps/deploy-web/src/pages/api/auth/[...auth0].ts
index 310e831d54..6c751d3b31 100644
--- a/apps/deploy-web/src/pages/api/auth/[...auth0].ts
+++ b/apps/deploy-web/src/pages/api/auth/[...auth0].ts
@@ -24,8 +24,8 @@ const authHandler = once((services: AppServices) =>
handleAuth({
async login(req: NextApiRequest, res: NextApiResponse) {
const returnUrl = decodeURIComponent((req.query.from as string) ?? "/");
- if (services.config.AUTH0_LOCAL_ENABLED && services.config.AUTH0_REDIRECT_BASE_URL) {
- rewriteLocalRedirect(res, services.config);
+ if (services.privateConfig.AUTH0_LOCAL_ENABLED && services.privateConfig.AUTH0_REDIRECT_BASE_URL) {
+ rewriteLocalRedirect(res, services.privateConfig);
}
await handleLogin(req, res, {
@@ -44,7 +44,7 @@ const authHandler = once((services: AppServices) =>
try {
const userSettings = await services.sessionService.createLocalUser(session);
session.user = { ...session.user, ...userSettings };
- const isSecure = services.config.NODE_ENV === "production";
+ const isSecure = services.privateConfig.NODE_ENV === "production";
res.setHeader(
"Set-Cookie",
`${ANONYMOUS_HEADER_COOKIE_NAME}=; Path=/api/auth/callback; HttpOnly; ${isSecure ? "Secure;" : ""} SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT`
@@ -61,7 +61,7 @@ const authHandler = once((services: AppServices) =>
throw error;
}
},
- logout: services.config.AUTH0_LOCAL_ENABLED
+ logout: services.privateConfig.AUTH0_LOCAL_ENABLED
? async function (req: NextApiRequest, res: NextApiResponse) {
const cookies = req.cookies;
const expiredCookies = Object.keys(cookies)
diff --git a/apps/deploy-web/src/pages/api/auth/password-login.ts b/apps/deploy-web/src/pages/api/auth/password-login.ts
index ce4ef22e1e..17dea9df82 100644
--- a/apps/deploy-web/src/pages/api/auth/password-login.ts
+++ b/apps/deploy-web/src/pages/api/auth/password-login.ts
@@ -2,16 +2,24 @@ import { z } from "zod";
import { setSession } from "@src/lib/auth0/setSession/setSession";
import { defineApiHandler } from "@src/lib/nextjs/defineApiHandler/defineApiHandler";
+import { verifyCaptcha } from "@src/middleware/verify-captcha/verify-captcha";
export default defineApiHandler({
route: "/api/auth/password-login",
+ method: "POST",
schema: z.object({
body: z.object({
email: z.string().email(),
- password: z.string()
+ password: z.string(),
+ captchaToken: z.string()
})
}),
- async handler({ res, req, services }) {
+ async handler(ctx) {
+ const { res, req, services, body } = ctx;
+
+ const verification = await verifyCaptcha(body.captchaToken, ctx);
+ if (verification.err) return res.status(400).json(verification.val);
+
const result = await services.sessionService.signIn({
email: req.body.email,
password: req.body.password
diff --git a/apps/deploy-web/src/pages/api/auth/password-signup.ts b/apps/deploy-web/src/pages/api/auth/password-signup.ts
index b0e19ca476..74e25026ca 100644
--- a/apps/deploy-web/src/pages/api/auth/password-signup.ts
+++ b/apps/deploy-web/src/pages/api/auth/password-signup.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import { setSession } from "@src/lib/auth0/setSession/setSession";
import { defineApiHandler } from "@src/lib/nextjs/defineApiHandler/defineApiHandler";
+import { verifyCaptcha } from "@src/middleware/verify-captcha/verify-captcha";
const LOWER_LETTER_REGEX = /\p{Ll}/u;
const UPPER_LETTER_REGEX = /\p{Lu}/u;
@@ -10,6 +11,7 @@ const SPECIAL_CHAR_REGEX = /[^\p{L}\p{N}]/u;
export default defineApiHandler({
route: "/api/auth/password-signup",
+ method: "POST",
schema: z.object({
body: z.object({
email: z.string().email(),
@@ -30,10 +32,16 @@ export default defineApiHandler({
),
termsAndConditions: z.boolean().refine(value => value, {
message: "Please accept the terms and conditions"
- })
+ }),
+ captchaToken: z.string()
})
}),
- async handler({ res, req, services }) {
+ async handler(ctx) {
+ const { res, req, services, body } = ctx;
+
+ const verification = await verifyCaptcha(body.captchaToken, ctx);
+ if (verification.err) return res.status(400).json(verification.val);
+
const result = await services.sessionService.signUp({
email: req.body.email,
password: req.body.password
diff --git a/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts b/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts
index af459e8e26..cf297e8e60 100644
--- a/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts
+++ b/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts
@@ -1,20 +1,24 @@
import { z } from "zod";
import { defineApiHandler } from "@src/lib/nextjs/defineApiHandler/defineApiHandler";
+import { verifyCaptcha } from "@src/middleware/verify-captcha/verify-captcha";
export default defineApiHandler({
route: "/api/auth/send-password-reset-email",
+ method: "POST",
schema: z.object({
body: z.object({
- email: z.string().email()
+ email: z.string().email(),
+ captchaToken: z.string()
})
}),
- async handler({ res, req, services }) {
- if (req.method !== "POST") {
- return res.status(405).json({ message: "Method not allowed" });
- }
+ async handler(ctx) {
+ const { res, req, services, body } = ctx;
try {
+ const verification = await verifyCaptcha(body.captchaToken, ctx);
+ if (verification.err) return res.status(400).json(verification.val);
+
const result = await services.sessionService.sendPasswordResetEmail({ email: req.body.email });
if (result.ok) {
res.status(204).end();
diff --git a/apps/deploy-web/src/pages/api/auth/signup.ts b/apps/deploy-web/src/pages/api/auth/signup.ts
index d9bedc885b..4d16c28e13 100644
--- a/apps/deploy-web/src/pages/api/auth/signup.ts
+++ b/apps/deploy-web/src/pages/api/auth/signup.ts
@@ -9,8 +9,8 @@ export default defineApiHandler({
route: "/api/auth/signup",
async handler({ res, req, services }) {
try {
- if (services.config.AUTH0_LOCAL_ENABLED && services.config.AUTH0_REDIRECT_BASE_URL) {
- rewriteLocalRedirect(res, services.config);
+ if (services.privateConfig.AUTH0_LOCAL_ENABLED && services.privateConfig.AUTH0_REDIRECT_BASE_URL) {
+ rewriteLocalRedirect(res, services.privateConfig);
}
const returnUrl = decodeURIComponent((req.query.returnTo as string) ?? "/");
@@ -20,7 +20,7 @@ export default defineApiHandler({
// then we set cookie and return 204 status, the actual call will be made by in-browser redirect
if (token) {
const lifetime = 5 * 60; // 5 minutes
- const isSecure = services.config.NODE_ENV === "production";
+ const isSecure = services.privateConfig.NODE_ENV === "production";
res.setHeader(
"Set-Cookie",
`${ANONYMOUS_HEADER_COOKIE_NAME}=${encodeURIComponent(token.replace(/^Bearer\s+/i, ""))}; HttpOnly; ${isSecure ? "Secure;" : ""} SameSite=Lax; Path=/api/auth/callback; Max-Age=${lifetime}`
diff --git a/apps/deploy-web/src/pages/api/bitbucket/authenticate.ts b/apps/deploy-web/src/pages/api/bitbucket/authenticate.ts
index d0dd4946ae..cc9cf5229a 100644
--- a/apps/deploy-web/src/pages/api/bitbucket/authenticate.ts
+++ b/apps/deploy-web/src/pages/api/bitbucket/authenticate.ts
@@ -11,7 +11,7 @@ export default defineApiHandler({
})
}),
async handler({ body, res, services }) {
- const { NEXT_PUBLIC_BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET } = services.config;
+ const { NEXT_PUBLIC_BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET } = services.privateConfig;
const bitbucketAuth = new BitbucketAuth(NEXT_PUBLIC_BITBUCKET_CLIENT_ID!, BITBUCKET_CLIENT_SECRET, services.externalApiHttpClient);
const tokens = await bitbucketAuth.exchangeAuthorizationCodeForTokens(body.code);
diff --git a/apps/deploy-web/src/pages/api/bitbucket/refresh.ts b/apps/deploy-web/src/pages/api/bitbucket/refresh.ts
index 137455c562..a68edbb042 100644
--- a/apps/deploy-web/src/pages/api/bitbucket/refresh.ts
+++ b/apps/deploy-web/src/pages/api/bitbucket/refresh.ts
@@ -11,7 +11,7 @@ export default defineApiHandler({
})
}),
async handler({ body, res, services }) {
- const { NEXT_PUBLIC_BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET } = services.config;
+ const { NEXT_PUBLIC_BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET } = services.privateConfig;
const bitbucketAuth = new BitbucketAuth(NEXT_PUBLIC_BITBUCKET_CLIENT_ID!, BITBUCKET_CLIENT_SECRET, services.externalApiHttpClient);
const tokens = await bitbucketAuth.refreshTokensUsingRefreshToken(body.refreshToken);
diff --git a/apps/deploy-web/src/pages/api/github/authenticate.ts b/apps/deploy-web/src/pages/api/github/authenticate.ts
index 07cfbdbd1c..20461452bb 100644
--- a/apps/deploy-web/src/pages/api/github/authenticate.ts
+++ b/apps/deploy-web/src/pages/api/github/authenticate.ts
@@ -11,7 +11,7 @@ export default defineApiHandler({
})
}),
async handler({ body, res, services }) {
- const { NEXT_PUBLIC_GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, NEXT_PUBLIC_REDIRECT_URI } = services.config;
+ const { NEXT_PUBLIC_GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, NEXT_PUBLIC_REDIRECT_URI } = services.privateConfig;
const gitHubAuth = new GitHubAuth(
NEXT_PUBLIC_GITHUB_CLIENT_ID as string,
GITHUB_CLIENT_SECRET as string,
diff --git a/apps/deploy-web/src/pages/api/gitlab/authenticate.ts b/apps/deploy-web/src/pages/api/gitlab/authenticate.ts
index 9a2e2105c0..2f9729c2f4 100644
--- a/apps/deploy-web/src/pages/api/gitlab/authenticate.ts
+++ b/apps/deploy-web/src/pages/api/gitlab/authenticate.ts
@@ -11,7 +11,7 @@ export default defineApiHandler({
})
}),
async handler({ body, res, services }) {
- const { NEXT_PUBLIC_GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, NEXT_PUBLIC_REDIRECT_URI } = services.config;
+ const { NEXT_PUBLIC_GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, NEXT_PUBLIC_REDIRECT_URI } = services.privateConfig;
const gitlabAuth = new GitlabAuth(
NEXT_PUBLIC_GITLAB_CLIENT_ID as string,
GITLAB_CLIENT_SECRET as string,
diff --git a/apps/deploy-web/src/pages/api/gitlab/refresh.ts b/apps/deploy-web/src/pages/api/gitlab/refresh.ts
index d12044bd6a..379145d6a4 100644
--- a/apps/deploy-web/src/pages/api/gitlab/refresh.ts
+++ b/apps/deploy-web/src/pages/api/gitlab/refresh.ts
@@ -11,7 +11,7 @@ export default defineApiHandler({
})
}),
async handler({ body, res, services }) {
- const { NEXT_PUBLIC_GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET } = services.config;
+ const { NEXT_PUBLIC_GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET } = services.privateConfig;
const gitlabAuth = new GitlabAuth(NEXT_PUBLIC_GITLAB_CLIENT_ID as string, GITLAB_CLIENT_SECRET as string, undefined, services.externalApiHttpClient);
const tokens = await gitlabAuth.refreshTokensUsingRefreshToken(body.refreshToken);
diff --git a/apps/deploy-web/src/pages/api/proxy/[...path].ts b/apps/deploy-web/src/pages/api/proxy/[...path].ts
index 0d434f3553..663faa11a1 100644
--- a/apps/deploy-web/src/pages/api/proxy/[...path].ts
+++ b/apps/deploy-web/src/pages/api/proxy/[...path].ts
@@ -38,7 +38,7 @@ export default defineApiHandler({
const proxy = services.httpProxy.createProxyServer({
changeOrigin: true,
- target: services.apiUrlService.getBaseApiUrlFor(services.config.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID),
+ target: services.apiUrlService.getBaseApiUrlFor(services.privateConfig.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID),
secure: false,
autoRewrite: false,
headers
diff --git a/apps/deploy-web/src/pages/profile/[username]/index.tsx b/apps/deploy-web/src/pages/profile/[username]/index.tsx
index 5875f0eecf..6371bd68ad 100644
--- a/apps/deploy-web/src/pages/profile/[username]/index.tsx
+++ b/apps/deploy-web/src/pages/profile/[username]/index.tsx
@@ -25,7 +25,7 @@ export const getServerSideProps = defineServerSideProps({
}),
async handler({ params, services }): Promise> {
const { data: user } = await services.consoleApiHttpClient.get(
- `${services.apiUrlService.getBaseApiUrlFor(services.config.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID)}/user/byUsername/${params.username}`
+ `${services.apiUrlService.getBaseApiUrlFor(services.privateConfig.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID)}/user/byUsername/${params.username}`
);
return {
diff --git a/apps/deploy-web/src/pages/template/[id]/index.tsx b/apps/deploy-web/src/pages/template/[id]/index.tsx
index 6f66590104..58a8a9342c 100644
--- a/apps/deploy-web/src/pages/template/[id]/index.tsx
+++ b/apps/deploy-web/src/pages/template/[id]/index.tsx
@@ -35,7 +35,7 @@ export const getServerSideProps = defineServerSideProps({
};
}
const response = await services.consoleApiHttpClient.get(
- `${services.apiUrlService.getBaseApiUrlFor(services.config.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID)}/user/template/${params.id}`,
+ `${services.apiUrlService.getBaseApiUrlFor(services.privateConfig.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID)}/user/template/${params.id}`,
config
);
diff --git a/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts b/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts
index 539abbc274..612d1dd572 100644
--- a/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts
+++ b/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts
@@ -10,6 +10,7 @@ import { clientIpForwardingInterceptor } from "../client-ip-forwarding/client-ip
import { createChildContainer } from "../container/createContainer";
import { FeatureFlagService } from "../feature-flag/feature-flag.service";
import { SessionService } from "../session/session.service";
+import { TurnstileVerifierService } from "../turnstile-verifier/turnstile-verifier.service";
import { createAppRootContainer } from "./app-di-container";
const rootContainer = createAppRootContainer({
@@ -28,16 +29,16 @@ export const services = createChildContainer(rootContainer, {
notificationsApi: () =>
createAPIClient({
requestFn,
- baseUrl: services.apiUrlService.getBaseApiUrlFor(services.config.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID)
+ baseUrl: services.apiUrlService.getBaseApiUrlFor(services.privateConfig.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID)
}),
- config: () => serverEnvConfig,
+ privateConfig: () => Object.freeze(serverEnvConfig),
consoleApiHttpClient: () => services.applyAxiosInterceptors(services.createAxios()),
sessionService: () =>
new SessionService(
services.externalApiHttpClient,
services.applyAxiosInterceptors(
services.createAxios({
- baseURL: services.apiUrlService.getBaseApiUrlFor(services.config.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID),
+ baseURL: services.apiUrlService.getBaseApiUrlFor(services.privateConfig.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID),
headers: {
"Content-Type": "application/json; charset=utf-8",
Accept: "application/json"
@@ -45,12 +46,18 @@ export const services = createChildContainer(rootContainer, {
})
),
{
- ISSUER_BASE_URL: services.config.AUTH0_ISSUER_BASE_URL,
- CLIENT_ID: services.config.AUTH0_CLIENT_ID,
- CLIENT_SECRET: services.config.AUTH0_CLIENT_SECRET,
- AUDIENCE: services.config.AUTH0_AUDIENCE
+ ISSUER_BASE_URL: services.privateConfig.AUTH0_ISSUER_BASE_URL,
+ CLIENT_ID: services.privateConfig.AUTH0_CLIENT_ID,
+ CLIENT_SECRET: services.privateConfig.AUTH0_CLIENT_SECRET,
+ AUDIENCE: services.privateConfig.AUTH0_AUDIENCE
}
- )
+ ),
+ captchaVerifier: () =>
+ new TurnstileVerifierService(services.externalApiHttpClient, {
+ secretKey: services.privateConfig.TURNSTILE_SECRET_KEY,
+ turnstileBypassSecretKey: services.privateConfig.TURNSTILE_BYPASS_SECRET_KEY,
+ bypassSecretKeyVerificationToken: services.privateConfig.E2E_TESTING_CLIENT_TOKEN
+ })
});
export type AppServices = typeof services;
diff --git a/apps/deploy-web/src/services/auth/auth/auth.service.ts b/apps/deploy-web/src/services/auth/auth/auth.service.ts
index 5571be2d10..b39df929f8 100644
--- a/apps/deploy-web/src/services/auth/auth/auth.service.ts
+++ b/apps/deploy-web/src/services/auth/auth/auth.service.ts
@@ -29,24 +29,27 @@ export class AuthService {
this.location.assign(this.urlService.signup() + (queryParams ? `?${queryParams}` : ""));
}
- async login(input: { email: string; password: string }): Promise {
+ async login(input: { email: string; password: string; captchaToken: string }): Promise {
await this.internalApiHttpClient.post("/api/auth/password-login", {
email: input.email,
- password: input.password
+ password: input.password,
+ captchaToken: input.captchaToken
});
}
- async signup(input: { email: string; password: string; termsAndConditions: boolean }): Promise {
+ async signup(input: { email: string; password: string; termsAndConditions: boolean; captchaToken: string }): Promise {
await this.internalApiHttpClient.post("/api/auth/password-signup", {
email: input.email,
password: input.password,
- termsAndConditions: input.termsAndConditions
+ termsAndConditions: input.termsAndConditions,
+ captchaToken: input.captchaToken
});
}
- async sendPasswordResetEmail(input: { email: string }): Promise {
+ async sendPasswordResetEmail(input: { email: string; captchaToken: string }): Promise {
await this.internalApiHttpClient.post("/api/auth/send-password-reset-email", {
- email: input.email
+ email: input.email,
+ captchaToken: input.captchaToken
});
}
diff --git a/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.spec.ts b/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.spec.ts
deleted file mode 100644
index 3f86f62a98..0000000000
--- a/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.spec.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import { createSign, generateKeyPairSync, webcrypto } from "crypto";
-
-import type { BrowserEnvConfig } from "@src/config/env-config.schema";
-import { decodeInjectedConfig } from "./decodeInjectedConfig";
-
-describe(decodeInjectedConfig.name, () => {
- beforeAll(() => {
- if (!window.crypto.subtle) {
- Object.defineProperty(globalThis, "crypto", {
- value: webcrypto as any,
- writable: true
- });
- }
- });
-
- it("returns null if public key is not provided", async () => {
- let config = await decodeInjectedConfig(undefined);
- expect(config).toBeNull();
-
- config = await decodeInjectedConfig();
- expect(config).toBeNull();
- });
-
- it("returns null if __AK_INJECTED_CONFIG__ is not a string", async () => {
- (window as any).__AK_INJECTED_CONFIG__ = 1;
- let config = await decodeInjectedConfig("test pem");
- expect(config).toBeNull();
-
- (window as any).__AK_INJECTED_CONFIG__ = undefined;
- config = await decodeInjectedConfig("test pem");
- expect(config).toBeNull();
- });
-
- it("return null if __AK_INJECTED_CONFIG__ is of invalid format", async () => {
- (window as any).__AK_INJECTED_CONFIG__ = "test config";
- let config = await decodeInjectedConfig("test pem");
- expect(config).toBeNull();
-
- (window as any).__AK_INJECTED_CONFIG__ = "test config.";
- config = await decodeInjectedConfig("test pem");
- expect(config).toBeNull();
-
- (window as any).__AK_INJECTED_CONFIG__ = ".test signature";
- config = await decodeInjectedConfig("test pem");
- expect(config).toBeNull();
- });
-
- it("returns null if signature is invalid", async () => {
- const config = {
- NEXT_PUBLIC_TURNSTILE_SITE_KEY: "test site key"
- };
- const { signedConfig } = signConfig(config);
- const { publicKey } = generateKeyPairSync("rsa", {
- modulusLength: 2048
- });
- (window as any).__AK_INJECTED_CONFIG__ = signedConfig;
- const decodedConfig = await decodeInjectedConfig(publicKey.export({ type: "spki", format: "pem" }) as string);
- expect(decodedConfig).toBeNull();
- });
-
- it("returns null if config is not a valid JSON", async () => {
- const { signedConfig, publicKey } = signConfig({});
- const [, signature] = signedConfig.split(".", 2);
- (window as any).__AK_INJECTED_CONFIG__ = `not valid json.${signature}`;
-
- const decodedConfig = await decodeInjectedConfig(publicKey.export({ type: "spki", format: "pem" }) as string);
- expect(decodedConfig).toBeNull();
- });
-
- it("returns config if signature is valid", async () => {
- const config = {
- NEXT_PUBLIC_TURNSTILE_SITE_KEY: "test site key"
- };
- const { signedConfig, publicKey } = signConfig(config);
-
- (window as any).__AK_INJECTED_CONFIG__ = signedConfig;
- const decodedConfig = await decodeInjectedConfig(publicKey.export({ type: "spki", format: "pem" }) as string);
- expect(decodedConfig).toEqual(config);
- });
-
- function signConfig(config: Partial) {
- const { publicKey, privateKey } = generateKeyPairSync("rsa", {
- modulusLength: 2048
- });
- const serializedConfig = JSON.stringify(config);
-
- const sign = createSign("SHA256");
- sign.update(JSON.stringify(config));
- sign.end();
- const signature = sign.sign(privateKey, "base64");
-
- return {
- signedConfig: `${serializedConfig}.${signature}`,
- privateKey,
- publicKey
- };
- }
-});
diff --git a/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.ts b/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.ts
deleted file mode 100644
index 959a5619f1..0000000000
--- a/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { BrowserEnvConfig } from "@src/config/env-config.schema";
-
-export function hasInjectedConfig(): boolean {
- return typeof window !== "undefined" && !!(window as any)?.__AK_INJECTED_CONFIG__;
-}
-
-export async function decodeInjectedConfig(publicPem = process.env.NEXT_PUBLIC_UI_CONFIG_PUBLIC_KEY): Promise | null> {
- if (!publicPem) return null;
-
- const signedConfig = (window as any).__AK_INJECTED_CONFIG__;
- if (!signedConfig || typeof signedConfig !== "string") return null;
-
- const [config, signature] = signedConfig.split(".", 2);
- if (!config || !signature) return null;
-
- const publicKey = await crypto.subtle.importKey(
- "spki",
- base64ToArrayBuffer(publicPem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "")),
- {
- name: "RSASSA-PKCS1-v1_5",
- hash: "SHA-256"
- },
- false,
- ["verify"]
- );
- const isValidSignature = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, base64ToArrayBuffer(signature), new TextEncoder().encode(config));
- if (!isValidSignature) return null;
-
- try {
- return JSON.parse(config);
- } catch {
- return null;
- }
-}
-
-function base64ToArrayBuffer(base64: string) {
- const binary = atob(base64);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) {
- bytes[i] = binary.charCodeAt(i);
- }
- return bytes;
-}
diff --git a/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.spec.ts b/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.spec.ts
new file mode 100644
index 0000000000..2e16199b74
--- /dev/null
+++ b/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.spec.ts
@@ -0,0 +1,87 @@
+import type { HttpClient } from "@akashnetwork/http-sdk";
+import type { MockProxy } from "jest-mock-extended";
+import { mock } from "jest-mock-extended";
+
+import { TurnstileVerifierService } from "./turnstile-verifier.service";
+
+describe(TurnstileVerifierService.name, () => {
+ describe("verify", () => {
+ it("returns Ok when verification succeeds", async () => {
+ const { service, httpClient, config } = setup();
+ httpClient.post.mockResolvedValue({ data: { success: true } });
+
+ const result = await service.verify("valid-token");
+
+ expect(result.ok).toBe(true);
+ expect(httpClient.post).toHaveBeenCalledWith(
+ expect.any(String),
+ { secret: config.secretKey, response: "valid-token" },
+ expect.objectContaining({
+ headers: { "Content-Type": "application/json" }
+ })
+ );
+ });
+
+ it("includes remoteIp in payload when provided", async () => {
+ const { service, httpClient, config } = setup();
+ httpClient.post.mockResolvedValue({ data: { success: true } });
+
+ await service.verify("test-token", { remoteIp: "192.168.1.1" });
+
+ expect(httpClient.post).toHaveBeenCalledWith(
+ expect.any(String),
+ { secret: config.secretKey, response: "test-token", remoteip: "192.168.1.1" },
+ expect.any(Object)
+ );
+ });
+
+ it("returns Err with error codes when verification fails", async () => {
+ const { service, httpClient } = setup();
+ httpClient.post.mockResolvedValue({
+ data: { success: false, "error-codes": ["invalid-input-response"] }
+ });
+
+ const result = await service.verify("invalid-token");
+
+ expect(result.ok).toBe(false);
+ expect(result.val).toEqual({
+ code: "verification_failed",
+ errorCodes: ["invalid-input-response"]
+ });
+ });
+
+ it("uses bypass secret key when bypass verification token matches", async () => {
+ const { service, httpClient, config } = setup();
+ httpClient.post.mockResolvedValue({ data: { success: true } });
+
+ await service.verify("test-token", {
+ bypassVerificationToken: config.bypassSecretKeyVerificationToken
+ });
+
+ expect(httpClient.post).toHaveBeenCalledWith(expect.any(String), { secret: config.turnstileBypassSecretKey, response: "test-token" }, expect.any(Object));
+ });
+
+ it("uses regular secret key when bypass verification token does not match", async () => {
+ const { service, httpClient, config } = setup();
+ httpClient.post.mockResolvedValue({ data: { success: true } });
+
+ await service.verify("test-token", {
+ bypassVerificationToken: "wrong-bypass-token"
+ });
+
+ expect(httpClient.post).toHaveBeenCalledWith(expect.any(String), { secret: config.secretKey, response: "test-token" }, expect.any(Object));
+ });
+ });
+
+ function setup() {
+ const httpClient: MockProxy = mock();
+ const config = {
+ secretKey: "test-secret-key",
+ turnstileBypassSecretKey: "test-bypass-secret-key",
+ bypassSecretKeyVerificationToken: "test-bypass-verification-token"
+ };
+ const service = new TurnstileVerifierService(httpClient, config);
+
+ return { service, httpClient, config };
+ }
+});
diff --git a/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.ts b/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.ts
new file mode 100644
index 0000000000..3ea221c12b
--- /dev/null
+++ b/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.ts
@@ -0,0 +1,84 @@
+import type { HttpClient } from "@akashnetwork/http-sdk";
+import type { Result } from "ts-results";
+import { Err, Ok } from "ts-results";
+
+export class TurnstileVerifierService {
+ readonly #config: TurnstileVerifierConfig;
+ /**
+ * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
+ */
+ readonly #verificationUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
+ readonly #externalApiHttpClient: HttpClient;
+
+ constructor(externalApiHttpClient: HttpClient, config: TurnstileVerifierConfig) {
+ this.#externalApiHttpClient = externalApiHttpClient;
+ this.#config = config;
+ }
+
+ async verify(
+ token: string,
+ options?: {
+ remoteIp?: string;
+ bypassVerificationToken?: string;
+ }
+ ): Promise<
+ Result<
+ void,
+ {
+ code: "verification_failed";
+ errorCodes: Required["error-codes"];
+ }
+ >
+ > {
+ const canUseBypassSecretKey =
+ this.#config.bypassSecretKeyVerificationToken && options?.bypassVerificationToken === this.#config.bypassSecretKeyVerificationToken;
+ const payload: Record = {
+ secret: canUseBypassSecretKey ? this.#config.turnstileBypassSecretKey : this.#config.secretKey,
+ response: token
+ };
+ if (options?.remoteIp) {
+ payload.remoteip = options.remoteIp;
+ }
+
+ const { data } = await this.#externalApiHttpClient.post(this.#verificationUrl, payload, {
+ headers: {
+ "Content-Type": "application/json"
+ },
+ validateStatus: status => status < 500
+ });
+
+ if (data.success) return Ok(undefined);
+
+ return Err({
+ code: "verification_failed",
+ errorCodes: data["error-codes"] || []
+ });
+ }
+}
+
+interface TurnstileVerifyResponse {
+ success: boolean;
+ "error-codes"?: Array<
+ | "missing-input-secret"
+ | "invalid-input-secret"
+ | "missing-input-response"
+ | "invalid-input-response"
+ | "bad-request"
+ | "timeout-or-duplicate"
+ | "internal-error"
+ >;
+ challenge_ts?: string;
+ hostname?: string;
+}
+
+interface TurnstileVerifierConfig {
+ secretKey: string;
+ /**
+ * @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/#test-secret-keys
+ */
+ turnstileBypassSecretKey: string;
+ /**
+ * Token used to enable bypassing Turnstile's secret key verification
+ */
+ bypassSecretKeyVerificationToken: string;
+}
diff --git a/apps/deploy-web/src/utils/getInjectedConfig/getInjectedConfig.ts b/apps/deploy-web/src/utils/getInjectedConfig/getInjectedConfig.ts
new file mode 100644
index 0000000000..2ff0672b9a
--- /dev/null
+++ b/apps/deploy-web/src/utils/getInjectedConfig/getInjectedConfig.ts
@@ -0,0 +1,5 @@
+import type { BrowserEnvConfig } from "@src/config/env-config.schema";
+
+export function getInjectedConfig(): Partial | undefined {
+ return typeof window !== "undefined" ? (window as any).__AK_INJECTED_CONFIG__ : undefined;
+}
diff --git a/apps/deploy-web/tests/ui/fixture/base-test.ts b/apps/deploy-web/tests/ui/fixture/base-test.ts
index 06ae86ddc7..a2b5d60d91 100644
--- a/apps/deploy-web/tests/ui/fixture/base-test.ts
+++ b/apps/deploy-web/tests/ui/fixture/base-test.ts
@@ -1,9 +1,6 @@
import type { Page } from "@playwright/test";
import { test as baseTest } from "@playwright/test";
-import type { BrowserEnvConfig } from "@src/config/browser-env.config";
-import { testEnvConfig } from "./test-env.config";
-
export * from "@playwright/test";
export const test = baseTest.extend({
@@ -13,47 +10,13 @@ export const test = baseTest.extend({
}
});
-export async function injectUIConfig(page: Page) {
- if (!testEnvConfig.UI_CONFIG_SIGNATURE_PRIVATE_KEY) {
- return;
- }
-
- const uiConfig = await getSignedConfig(testEnvConfig.UI_CONFIG_SIGNATURE_PRIVATE_KEY);
- await page.addInitScript(stringifiedConfig => {
- (window as any).__AK_INJECTED_CONFIG__ = stringifiedConfig;
- }, uiConfig);
-}
-
-const signedConfigCache = new Map();
-async function getSignedConfig(privateKeyPem: string) {
- if (signedConfigCache.has(privateKeyPem)) {
- return signedConfigCache.get(privateKeyPem);
- }
-
- const config: Partial = {
- // always pass token: https://deelopers.cloudflare.com/turnstile/troubleshooting/testing/#dummy-sitekeys-and-secret-keys
- NEXT_PUBLIC_TURNSTILE_SITE_KEY: "1x00000000000000000000AA"
- };
- const serializedConfig = JSON.stringify(config);
- const privateKey = await importPrivateKey(privateKeyPem);
- const signatureBuffer = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, Buffer.from(serializedConfig));
-
- const result = `${serializedConfig}.${Buffer.from(signatureBuffer).toString("base64")}`;
- signedConfigCache.set(privateKeyPem, result);
- return result;
-}
-
-async function importPrivateKey(pem: string) {
- const der = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""), "base64");
+export const expect = test.expect;
- return await crypto.subtle.importKey(
- "pkcs8",
- der,
- {
- name: "RSASSA-PKCS1-v1_5",
- hash: "SHA-256"
- },
- false,
- ["sign"]
- );
+export async function injectUIConfig(page: Page) {
+ await page.addInitScript(() => {
+ (window as any).__AK_INJECTED_CONFIG__ = Object.freeze({
+ // always pass turnstile site key: https://developers.cloudflare.com/turnstile/troubleshooting/testing/#dummy-sitekeys-and-secret-keys
+ NEXT_PUBLIC_TURNSTILE_SITE_KEY: "1x00000000000000000000AA"
+ });
+ });
}
diff --git a/apps/deploy-web/tests/ui/fixture/test-env.config.ts b/apps/deploy-web/tests/ui/fixture/test-env.config.ts
index 628f4809b9..6e2aeb1cb8 100644
--- a/apps/deploy-web/tests/ui/fixture/test-env.config.ts
+++ b/apps/deploy-web/tests/ui/fixture/test-env.config.ts
@@ -8,7 +8,6 @@ export const testEnvSchema = z.object({
.default("http://localhost:3000")
.transform(url => url.replace(/\/+$/, "")),
TEST_WALLET_MNEMONIC: z.string(),
- UI_CONFIG_SIGNATURE_PRIVATE_KEY: z.string().optional(),
NETWORK_ID: z.enum(["mainnet", "sandbox", "testnet"]).default("sandbox"),
USER_DATA_DIR: z.string().default(path.join(tmpdir(), "akash-console-web-ui-tests", crypto.randomUUID()))
});
@@ -16,7 +15,6 @@ export const testEnvSchema = z.object({
export const testEnvConfig = testEnvSchema.parse({
BASE_URL: process.env.BASE_URL,
TEST_WALLET_MNEMONIC: process.env.TEST_WALLET_MNEMONIC,
- UI_CONFIG_SIGNATURE_PRIVATE_KEY: process.env.UI_CONFIG_SIGNATURE_PRIVATE_KEY,
USER_DATA_DIR: process.env.USER_DATA_DIR
});