- {!ready ? (
-
Loading authentication...
- ) : !authenticated ? (
-
-
Connect your Sonar account to check your eligibility status.
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!authenticated) {
+ return (
+
+
+
Login to continue.
+
+ Login
+
+
+
+ );
+ }
+
+ if (!sonarConnected) {
+ return (
+
+
+
+
Connect your Sonar account to check your eligibility status.
+
+ Logout
+
+
Connect with Sonar
- ) : (
-
-
✓ Sonar Connected
-
- Disconnect from Sonar
+
+ );
+ }
+
+ return (
+
+
+
✓ Sonar Connected
+
+
+ Disconnect Sonar
+
+
+ Logout
- )}
+
);
}
diff --git a/src/app/components/registration/EntitiesList.tsx b/src/app/components/registration/EntitiesList.tsx
index 17b01f3..67c1a2f 100644
--- a/src/app/components/registration/EntitiesList.tsx
+++ b/src/app/components/registration/EntitiesList.tsx
@@ -1,6 +1,6 @@
import { EntityDetails } from "@echoxyz/sonar-core";
import { EntityCard } from "../entity/EntityCard";
-import { sonarConfig } from "@/app/config";
+import { sonarConfig } from "@/lib/config";
interface EntitiesListProps {
loading: boolean;
diff --git a/src/app/components/sale/PurchaseCard.tsx b/src/app/components/sale/PurchaseCard.tsx
index 2399f2c..ce3001d 100644
--- a/src/app/components/sale/PurchaseCard.tsx
+++ b/src/app/components/sale/PurchaseCard.tsx
@@ -1,14 +1,11 @@
"use client";
import { PrePurchaseFailureReason, GeneratePurchasePermitResponse, EntityID } from "@echoxyz/sonar-core";
+import { UseSonarPurchaseResultNotReadyToPurchase, UseSonarPurchaseResultReadyToPurchase } from "@echoxyz/sonar-react";
import { useState } from "react";
-import { saleUUID } from "../../config";
-import {
- useSonarPurchase,
- UseSonarPurchaseResultNotReadyToPurchase,
- UseSonarPurchaseResultReadyToPurchase,
-} from "@echoxyz/sonar-react";
-import { useSaleContract } from "../../hooks";
+import { saleUUID } from "@/lib/config";
+import { useSonarPurchase } from "../../hooks/use-sonar-purchase";
+import { useSaleContract } from "../../hooks/use-sale-contract";
function readinessConfig(
sonarPurchaser: UseSonarPurchaseResultReadyToPurchase | UseSonarPurchaseResultNotReadyToPurchase
@@ -46,7 +43,7 @@ function readinessConfig(
);
case PrePurchaseFailureReason.NO_RESERVED_ALLOCATION:
return warningConfig(
- "No reserved allocation — The connected wallet doesn’t have a reserved spot for this sale. Connect a different wallet."
+ "No reserved allocation — The connected wallet doesn't have a reserved spot for this sale. Connect a different wallet."
);
case PrePurchaseFailureReason.SALE_NOT_ACTIVE:
return errorConfig("The sale is not currently active.");
@@ -84,8 +81,8 @@ function ReadyToPurchaseSection({
// TODO: could support selecting the amount
amount: BigInt(1e8),
});
- } catch (error) {
- setError(error as Error);
+ } catch (err) {
+ setError(err as Error);
} finally {
setLoading(false);
}
@@ -135,11 +132,13 @@ function PurchaseCard({ entityID, walletAddress }: { entityID: EntityID; walletA
return
Loading...
;
}
- if (sonarPurchaser.error) {
+ if ("error" in sonarPurchaser && sonarPurchaser.error) {
return
Error: {sonarPurchaser.error.message}
;
}
- const readinessCfg = readinessConfig(sonarPurchaser);
+ // At this point we know it's either ready or not-ready (not loading, not error)
+ const purchaser = sonarPurchaser as UseSonarPurchaseResultReadyToPurchase | UseSonarPurchaseResultNotReadyToPurchase;
+ const readinessCfg = readinessConfig(purchaser);
return (
@@ -147,24 +146,23 @@ function PurchaseCard({ entityID, walletAddress }: { entityID: EntityID; walletA
{readinessCfg.description}
- {sonarPurchaser.readyToPurchase && (
+ {purchaser.readyToPurchase && (
)}
- {!sonarPurchaser.readyToPurchase &&
- sonarPurchaser.failureReason === PrePurchaseFailureReason.REQUIRES_LIVENESS && (
-
{
- window.open(sonarPurchaser.livenessCheckURL, "_blank");
- }}
- >
- Complete liveness check to purchase
-
- )}
+ {!purchaser.readyToPurchase && purchaser.failureReason === PrePurchaseFailureReason.REQUIRES_LIVENESS && (
+
{
+ window.open(purchaser.livenessCheckURL, "_blank");
+ }}
+ >
+ Complete liveness check to purchase
+
+ )}
);
}
diff --git a/src/app/hooks.ts b/src/app/hooks/use-sale-contract.ts
similarity index 96%
rename from src/app/hooks.ts
rename to src/app/hooks/use-sale-contract.ts
index d3900bc..a106146 100644
--- a/src/app/hooks.ts
+++ b/src/app/hooks/use-sale-contract.ts
@@ -1,8 +1,8 @@
import { BasicPermitV2, GeneratePurchasePermitResponse } from "@echoxyz/sonar-core";
import { useCallback, useEffect, useState } from "react";
import { useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
-import { saleContract } from "./config";
-import { examplSaleABI } from "./ExampleSaleABI";
+import { saleContract } from "@/lib/config";
+import { examplSaleABI } from "../ExampleSaleABI";
import { useConfig } from "wagmi";
import { simulateContract } from "wagmi/actions";
@@ -85,3 +85,4 @@ export const useSaleContract = (walletAddress: `0x${string}`) => {
awaitingTxReceiptError,
};
};
+
diff --git a/src/app/hooks/use-session.tsx b/src/app/hooks/use-session.tsx
new file mode 100644
index 0000000..15190e4
--- /dev/null
+++ b/src/app/hooks/use-session.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
+
+interface SessionState {
+ authenticated: boolean;
+ sonarConnected: boolean;
+ loading: boolean;
+}
+
+interface SessionContextValue extends SessionState {
+ login: () => Promise
;
+ logout: () => Promise;
+ refreshSession: () => Promise;
+}
+
+const SessionContext = createContext(null);
+
+export function SessionProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState({
+ authenticated: false,
+ sonarConnected: false,
+ loading: true,
+ });
+
+ const refreshSession = useCallback(async () => {
+ try {
+ const response = await fetch("/api/auth/session");
+ const data = await response.json();
+ setState({
+ authenticated: data.authenticated,
+ sonarConnected: data.sonarConnected,
+ loading: false,
+ });
+ } catch {
+ setState({
+ authenticated: false,
+ sonarConnected: false,
+ loading: false,
+ });
+ }
+ }, []);
+
+ useEffect(() => {
+ refreshSession();
+ }, [refreshSession]);
+
+ const login = useCallback(async () => {
+ try {
+ await fetch("/api/auth/login", { method: "POST" });
+ await refreshSession();
+ } catch (error) {
+ console.error("Login failed:", error);
+ }
+ }, [refreshSession]);
+
+ const logout = useCallback(async () => {
+ try {
+ await fetch("/api/auth/logout", { method: "POST" });
+ await refreshSession();
+ } catch (error) {
+ console.error("Logout failed:", error);
+ }
+ }, [refreshSession]);
+
+ return (
+ {children}
+ );
+}
+
+export function useSession() {
+ const context = useContext(SessionContext);
+ if (!context) {
+ throw new Error("useSession must be used within a SessionProvider");
+ }
+ return context;
+}
diff --git a/src/app/hooks/use-sonar-entities.ts b/src/app/hooks/use-sonar-entities.ts
new file mode 100644
index 0000000..6a6b14e
--- /dev/null
+++ b/src/app/hooks/use-sonar-entities.ts
@@ -0,0 +1,18 @@
+"use client";
+
+import { ListAvailableEntitiesResponse } from "@echoxyz/sonar-core";
+import { saleUUID } from "@/lib/config";
+import { useSonarQuery } from "./use-sonar-query";
+
+/**
+ * Hook to fetch all Sonar entities for the authenticated user
+ */
+export function useSonarEntities() {
+ const { loading, data, error } = useSonarQuery("/api/sonar/entities", { saleUUID });
+
+ return {
+ loading,
+ entities: data?.Entities,
+ error,
+ };
+}
diff --git a/src/app/hooks/use-sonar-entity.ts b/src/app/hooks/use-sonar-entity.ts
new file mode 100644
index 0000000..a1b6699
--- /dev/null
+++ b/src/app/hooks/use-sonar-entity.ts
@@ -0,0 +1,29 @@
+"use client";
+
+import { ReadEntityResponse } from "@echoxyz/sonar-core";
+import { saleUUID } from "@/lib/config";
+import { useSonarQuery } from "./use-sonar-query";
+
+/**
+ * Hook to fetch Sonar entity details for a specific wallet
+ */
+export function useSonarEntity(walletAddress?: string) {
+ const query = useSonarQuery("/api/sonar/entity", {
+ saleUUID,
+ walletAddress: walletAddress ?? "",
+ });
+
+ // No wallet address - return idle state
+ if (!walletAddress) {
+ return { loading: false, entity: undefined, error: undefined };
+ }
+
+ // Treat 404 as "no entity" rather than an error
+ const is404 = query.error?.message.includes("404");
+
+ return {
+ loading: query.loading,
+ entity: query.data?.Entity,
+ error: is404 ? undefined : query.error,
+ };
+}
diff --git a/src/app/hooks/use-sonar-purchase.ts b/src/app/hooks/use-sonar-purchase.ts
new file mode 100644
index 0000000..e6e816a
--- /dev/null
+++ b/src/app/hooks/use-sonar-purchase.ts
@@ -0,0 +1,72 @@
+"use client";
+
+import {
+ EntityID,
+ GeneratePurchasePermitResponse,
+ PrePurchaseCheckResponse,
+ PrePurchaseFailureReason,
+} from "@echoxyz/sonar-core";
+import { UseSonarPurchaseResult } from "@echoxyz/sonar-react";
+import { useSession } from "./use-session";
+import { useCallback } from "react";
+import { useSonarQuery } from "./use-sonar-query";
+
+/**
+ * Hook for Sonar purchase flow
+ */
+export function useSonarPurchase(args: {
+ saleUUID: string;
+ entityID: EntityID;
+ walletAddress: string;
+}): UseSonarPurchaseResult {
+ const { authenticated } = useSession();
+
+ const { loading, data, error } = useSonarQuery("/api/sonar/pre-purchase-check", {
+ saleUUID: args.saleUUID,
+ entityID: args.entityID,
+ walletAddress: args.walletAddress,
+ });
+
+ const generatePurchasePermit = useCallback(async (): Promise => {
+ if (!authenticated) {
+ throw new Error("Not authenticated");
+ }
+
+ const response = await fetch("/api/sonar/generate-purchase-permit", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ saleUUID: args.saleUUID,
+ entityID: args.entityID,
+ walletAddress: args.walletAddress,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || `Request failed: ${response.status}`);
+ }
+
+ return response.json();
+ }, [authenticated, args.saleUUID, args.entityID, args.walletAddress]);
+
+ if (loading) {
+ return { loading: true, readyToPurchase: false, error: undefined };
+ }
+
+ if (error || !data) {
+ return { loading: false, readyToPurchase: false, error: error ?? new Error("No data") };
+ }
+
+ if (data.ReadyToPurchase) {
+ return { loading: false, readyToPurchase: true, error: undefined, generatePurchasePermit };
+ }
+
+ return {
+ loading: false,
+ readyToPurchase: false,
+ error: undefined,
+ failureReason: data.FailureReason as PrePurchaseFailureReason,
+ livenessCheckURL: data.LivenessCheckURL,
+ };
+}
diff --git a/src/app/hooks/use-sonar-query.ts b/src/app/hooks/use-sonar-query.ts
new file mode 100644
index 0000000..e30a72c
--- /dev/null
+++ b/src/app/hooks/use-sonar-query.ts
@@ -0,0 +1,66 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useSession } from "./use-session";
+
+export type SonarQueryState =
+ | { loading: true; data: undefined; error: undefined }
+ | { loading: false; data: T; error: undefined }
+ | { loading: false; data: undefined; error: Error }
+ | { loading: false; data: undefined; error: undefined }; // idle/skipped state
+
+/**
+ * Generic hook for Sonar API queries with shared session/auth handling
+ */
+export function useSonarQuery(endpoint: string, body: Record): SonarQueryState {
+ const { authenticated, sonarConnected, refreshSession } = useSession();
+ const [state, setState] = useState>({
+ loading: true,
+ data: undefined,
+ error: undefined,
+ });
+
+ // Serialize body for stable dependency comparison
+ const serializedBody = JSON.stringify(body);
+
+ useEffect(() => {
+ if (!authenticated || !sonarConnected) {
+ setState({ loading: false, data: undefined, error: undefined });
+ return;
+ }
+
+ const fetchData = async () => {
+ setState({ loading: true, data: undefined, error: undefined });
+
+ try {
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: serializedBody,
+ });
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ await refreshSession();
+ return;
+ }
+ const errorData = await response.json();
+ throw new Error(errorData.error || `Request failed: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setState({ loading: false, data, error: undefined });
+ } catch (err) {
+ setState({
+ loading: false,
+ data: undefined,
+ error: err instanceof Error ? err : new Error(String(err)),
+ });
+ }
+ };
+
+ fetchData();
+ }, [authenticated, sonarConnected, endpoint, serializedBody, refreshSession]);
+
+ return state;
+}
diff --git a/src/app/oauth/callback/page.tsx b/src/app/oauth/callback/page.tsx
index 2e0b82a..2e2e2cf 100644
--- a/src/app/oauth/callback/page.tsx
+++ b/src/app/oauth/callback/page.tsx
@@ -1,102 +1,86 @@
"use client";
-import { useEffect, useRef, useState } from "react";
-import { useSonarAuth } from "@echoxyz/sonar-react";
+import { Suspense, useEffect, useState } from "react";
+import { useSearchParams } from "next/navigation";
-export default function OAuthCallback() {
- const { authenticated, completeOAuth, ready } = useSonarAuth();
- const oauthCompletionTriggered = useRef(false);
- const [oauthError, setOAuthError] = useState(null);
- const [timedOut, setTimedOut] = useState(false);
+/**
+ * OAuth callback content - uses useSearchParams which requires Suspense
+ */
+function OAuthCallbackContent() {
+ const searchParams = useSearchParams();
+ const [error, setError] = useState(null);
- // complete the oauth flow and exchange the code for an access token
useEffect(() => {
- const processOAuthCallback = async () => {
- const params = new URLSearchParams(window.location.search);
- const code = params.get("code");
- const state = params.get("state");
+ const handleCallback = async () => {
+ // Extract query parameters
+ const code = searchParams.get("code");
+ const state = searchParams.get("state");
+ const oauthError = searchParams.get("error");
- // the user is already authenticated, nothing to do
- if (!ready || authenticated || !code || !state) {
+ if (oauthError) {
+ setError(`OAuth error: ${oauthError}`);
return;
}
- // ensuring the oauth completion isn't called multiple times since subsequent ones are expected to fail
- if (oauthCompletionTriggered.current) {
+ if (!code || !state) {
+ setError("Missing authorization code or state");
return;
}
- oauthCompletionTriggered.current = true;
try {
- await completeOAuth({ code, state });
- } catch (err) {
- setOAuthError(err instanceof Error ? err.message : null);
- }
- };
-
- processOAuthCallback();
- }, [authenticated, completeOAuth, ready]);
-
- // fetch the user's available entities after they've been authenticated
- useEffect(() => {
- if (!ready || !authenticated) {
- return;
- }
+ // Call backend callback handler
+ const response = await fetch(`/api/auth/sonar/callback?code=${code}&state=${state}`);
- // Redirect to the stored return path, or default to home
- const returnPath = localStorage.getItem("sonar_oauth_return_path");
- if (returnPath) {
- localStorage.removeItem("sonar_oauth_return_path");
- window.location.href = returnPath;
- } else {
- window.location.href = "/";
- }
- }, [authenticated, ready]);
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({ error: "Unknown error" }));
+ setError(errorData.error || "Failed to complete OAuth flow");
+ return;
+ }
- // set a timeout, so we don't keep the user waiting indefinitely in case of an unexpected issue
- useEffect(() => {
- setTimedOut(false);
- const timeoutId = setTimeout(() => setTimedOut(true), 20000);
- return () => clearTimeout(timeoutId);
- }, [setTimedOut]);
-
- if (timedOut) {
- return (
-
-
-
Timed out
- (window.location.href = "/")}
- className="mt-4 px-4 py-2 bg-gray-50 text-gray-900 rounded-xl cursor-pointer"
- >
- Return to Login
-
-
-
- );
- }
+ // Success - do a hard navigation to home
+ // This ensures a full page load that picks up the updated session
+ window.location.href = "/";
+ } catch {
+ setError("Failed to process OAuth callback");
+ }
+ };
- if (oauthError) {
- return (
-
-
-
{oauthError}
- (window.location.href = "/")}
- className="mt-4 px-4 py-2 bg-gray-50 text-gray-900 rounded-xl cursor-pointer"
- >
- Return to Login
-
-
-
- );
- }
+ handleCallback();
+ }, [searchParams]);
return (
-
Connecting to Echo
+ {error ? (
+ <>
+
Error
+
{error}
+
Redirecting to home...
+ >
+ ) : (
+
Connecting to Sonar...
+ )}
);
}
+
+/**
+ * OAuth callback page - redirects to backend callback handler
+ * Wrapped in Suspense because useSearchParams requires it for static generation
+ */
+export default function OAuthCallback() {
+ return (
+
+
+
Loading...
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 57446d8..d946680 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,20 +1,24 @@
"use client";
import { useState, useEffect } from "react";
-import { ConnectKitButton } from "connectkit";
-import { useSonarAuth, useSonarEntity, useSonarEntities } from "@echoxyz/sonar-react";
-import { saleUUID, sonarHomeURL, sonarConfig } from "./config";
+import { saleUUID, sonarHomeURL } from "@/lib/config";
import { useAccount } from "wagmi";
-import PurchaseCard from "./components/sale/PurchaseCard";
import { SaleEligibility } from "@echoxyz/sonar-core";
-import { AuthenticationSection } from "./components/auth/AuthenticationSection";
import { EntityCard } from "./components/entity/EntityCard";
import { NotEligibleMessage } from "./components/sale/NotEligibleMessage";
+import { AuthenticationSection } from "./components/auth/AuthenticationSection";
+import { useSonarEntity } from "./hooks/use-sonar-entity";
+import { useSonarEntities } from "./hooks/use-sonar-entities";
+import { useSession } from "./hooks/use-session";
+import PurchaseCard from "./components/sale/PurchaseCard";
import { EntitiesList } from "./components/registration/EntitiesList";
import { EligibilityResults } from "./components/registration/EligibilityResults";
+import { ConnectKitButton } from "connectkit";
export default function Home() {
const [saleIsLive, setSaleIsLive] = useState(false);
+ const { sonarConnected } = useSession();
+ const { address } = useAccount();
// Load sale state from localStorage
useEffect(() => {
@@ -31,43 +35,26 @@ export default function Home() {
localStorage.setItem("sale_is_live", String(newState));
};
- // Auth and data hooks
- const { address } = useAccount();
- const { login, authenticated, logout, ready } = useSonarAuth();
-
- // Registration data
- const {
- loading: entitiesLoading,
- entities,
- error: entitiesError,
- } = useSonarEntities({
- saleUUID: saleUUID,
- });
+ // Registration data (all entities for the user)
+ const { loading: entitiesLoading, entities, error: entitiesError } = useSonarEntities();
const eligibleEntities = entities?.filter((entity) => entity.SaleEligibility === SaleEligibility.ELIGIBLE) || [];
- // Sale data
- const {
- loading: entityLoading,
- entity,
- error: entityError,
- } = useSonarEntity({
- saleUUID: saleUUID,
- walletAddress: address,
- });
+ // Sale data (entity for current wallet)
+ const { loading: entityLoading, entity, error: entityError } = useSonarEntity(address);
const isEligible = entity && entity.SaleEligibility === SaleEligibility.ELIGIBLE;
const EntitySection = () => {
- if (!address || !authenticated) {
+ if (!address) {
return (
Connection Required
-
Connect your wallet and Sonar account to continue with your purchase.
+
Connect your wallet to continue with your purchase.
);
}
-
+
if (entityLoading) {
return (
@@ -75,7 +62,7 @@ export default function Home() {
);
}
-
+
if (entityError) {
return (
@@ -84,7 +71,7 @@ export default function Home() {
);
}
-
+
if (!entity) {
return (
@@ -96,7 +83,7 @@ export default function Home() {
- {/* Registration Phase */}
- {!saleIsLive && (
-
-
+
+
- {authenticated && (
-
-
Check Your Eligibility
-
-
+ Check Your Eligibility
+
+
+
+ {!entitiesLoading && !entitiesError && entities && (
+
+ )}
+
+ )}
- {!entitiesLoading && !entitiesError && entities && (
-
- )}
-
- )}
-
- )}
-
- {/* Sale Phase */}
- {saleIsLive && (
-
- {/* Connection Buttons */}
-
-
- {/* Entity Information */}
+ {/* Sale Phase */}
+ {sonarConnected && saleIsLive && (
Your Entity Information
-
- {/* Purchase Panel */}
- {isEligible && address && (
-
- )}
+ {isEligible && address && (
+
+ )}
- {/* Not Eligible Message */}
- {entity && !isEligible &&
}
-
- )}
+ {entity && !isEligible &&
}
+
+ )}
+
diff --git a/src/app/config.ts b/src/lib/config.ts
similarity index 90%
rename from src/app/config.ts
rename to src/lib/config.ts
index 2862377..7dd1ab2 100644
--- a/src/app/config.ts
+++ b/src/lib/config.ts
@@ -1,12 +1,12 @@
import { Hex } from "@echoxyz/sonar-core";
import { SonarProviderConfig } from "@echoxyz/sonar-react";
-export const sonarConfig = {
+export const sonarConfig: SonarProviderConfig & { apiURL: string } = {
clientUUID: process.env.NEXT_PUBLIC_OAUTH_CLIENT_UUID ?? "",
redirectURI: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "",
frontendURL: process.env.NEXT_PUBLIC_ECHO_FRONTEND_URL ?? "https://app.echo.xyz",
apiURL: process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz",
-} as SonarProviderConfig;
+};
export const saleUUID = process.env.NEXT_PUBLIC_SALE_UUID ?? "";
export const saleContract =
diff --git a/src/lib/pkce-store.ts b/src/lib/pkce-store.ts
new file mode 100644
index 0000000..06e7312
--- /dev/null
+++ b/src/lib/pkce-store.ts
@@ -0,0 +1,111 @@
+/**
+ * PKCE store interface for managing OAuth PKCE code verifiers.
+ * This interface allows swapping between in-memory and persistent storage implementations.
+ */
+export interface PKCEEntry {
+ sessionId: string;
+ codeVerifier: string;
+ expiresAt: number; // Unix timestamp in milliseconds
+}
+
+export interface PKCEStore {
+ /**
+ * Store PKCE verifier and session ID for a state token
+ */
+ setEntry(state: string, entry: PKCEEntry): void;
+
+ /**
+ * Retrieve PKCE entry for a state token
+ */
+ getEntry(state: string): PKCEEntry | null;
+
+ /**
+ * Remove PKCE entry for a state token
+ */
+ clearEntry(state: string): void;
+}
+
+/**
+ * In-memory PKCE store implementation.
+ * Entries are stored in a Map and will be lost on server restart.
+ * This can be easily swapped for a database-backed implementation.
+ */
+class InMemoryPKCEStore implements PKCEStore {
+ private entries: Map