diff --git a/packages/connector/src/controller.ts b/packages/connector/src/controller.ts
index a221a8341..aaf33b958 100644
--- a/packages/connector/src/controller.ts
+++ b/packages/connector/src/controller.ts
@@ -1,5 +1,6 @@
import ControllerProvider, {
AuthOptions,
+ ConnectOptions,
ControllerOptions,
} from "@cartridge/controller";
import { Connector, InjectedConnector } from "@starknet-react/core";
@@ -39,8 +40,21 @@ export default class ControllerConnector extends InjectedConnector {
return await this.controller.delegateAccount();
}
- async connect(args?: { chainIdHint?: bigint; signupOptions?: AuthOptions }) {
- const account = await this.controller.connect(args?.signupOptions);
+ async connect(args?: {
+ chainIdHint?: bigint;
+ signupOptions?: AuthOptions;
+ locationGate?: ConnectOptions["locationGate"];
+ connectOptions?: ConnectOptions;
+ }) {
+ const connectOptions =
+ args?.connectOptions ??
+ (args?.signupOptions || args?.locationGate
+ ? {
+ signupOptions: args?.signupOptions,
+ locationGate: args?.locationGate,
+ }
+ : undefined);
+ const account = await this.controller.connect(connectOptions);
if (!account) {
throw new Error("Failed to connect controller");
}
diff --git a/packages/controller/src/controller.ts b/packages/controller/src/controller.ts
index c801df32f..9ebd07ac3 100644
--- a/packages/controller/src/controller.ts
+++ b/packages/controller/src/controller.ts
@@ -25,6 +25,8 @@ import {
ProfileContextTypeVariant,
ResponseCodes,
OpenOptions,
+ LocationPromptOptions,
+ ConnectOptions,
StarterpackOptions,
} from "./types";
import { validateRedirectUrl } from "./url-validator";
@@ -43,6 +45,29 @@ export default class ControllerProvider extends BaseProvider {
return !!this.keychain;
}
+ private normalizeConnectOptions(
+ options?: AuthOptions | ConnectOptions,
+ ): ConnectOptions {
+ if (Array.isArray(options)) {
+ return {
+ signupOptions: options,
+ locationGate: this.options.locationGate,
+ };
+ }
+
+ if (options && typeof options === "object") {
+ return {
+ signupOptions: options.signupOptions ?? this.options.signupOptions,
+ locationGate: options.locationGate ?? this.options.locationGate,
+ };
+ }
+
+ return {
+ signupOptions: this.options.signupOptions,
+ locationGate: this.options.locationGate,
+ };
+ }
+
constructor(options: ControllerOptions = {}) {
super();
@@ -226,7 +251,7 @@ export default class ControllerProvider extends BaseProvider {
}
async connect(
- signupOptions?: AuthOptions,
+ options?: AuthOptions | ConnectOptions,
): Promise
{
if (!this.iframes) {
return;
@@ -251,9 +276,12 @@ export default class ControllerProvider extends BaseProvider {
this.iframes.keychain.open();
try {
- // Use connect() parameter if provided, otherwise fall back to constructor options
- const effectiveOptions = signupOptions ?? this.options.signupOptions;
- let response = await this.keychain.connect(effectiveOptions);
+ const effectiveOptions = this.normalizeConnectOptions(options);
+ const connectPayload =
+ effectiveOptions.signupOptions || effectiveOptions.locationGate
+ ? effectiveOptions
+ : undefined;
+ let response = await this.keychain.connect(connectPayload);
if (response.code !== ResponseCodes.SUCCESS) {
throw new Error(response.message);
}
@@ -432,6 +460,26 @@ export default class ControllerProvider extends BaseProvider {
return this.keychain.username();
}
+ async openLocationPrompt(options?: LocationPromptOptions) {
+ if (!this.iframes) {
+ return;
+ }
+
+ if (!this.keychain || !this.iframes.keychain) {
+ console.error(new NotReadyToConnect().message);
+ return;
+ }
+
+ const responsePromise = this.keychain.openLocationPrompt(options);
+ this.iframes.keychain.open();
+
+ try {
+ return await responsePromise;
+ } finally {
+ this.iframes.keychain.close();
+ }
+ }
+
openPurchaseCredits() {
if (!this.iframes) {
return;
diff --git a/packages/controller/src/iframe/base.ts b/packages/controller/src/iframe/base.ts
index d49b85fe1..3dd066eab 100644
--- a/packages/controller/src/iframe/base.ts
+++ b/packages/controller/src/iframe/base.ts
@@ -54,7 +54,7 @@ export class IFrame implements Modal {
iframe.sandbox.add("allow-scripts");
iframe.sandbox.add("allow-same-origin");
iframe.allow =
- "publickey-credentials-create *; publickey-credentials-get *; clipboard-write; local-network-access *; payment *";
+ "publickey-credentials-create *; publickey-credentials-get *; clipboard-write; geolocation *; local-network-access *; payment *";
iframe.style.scrollbarWidth = "none";
iframe.style.setProperty("-ms-overflow-style", "none");
iframe.style.setProperty("-webkit-scrollbar", "none");
diff --git a/packages/controller/src/types.ts b/packages/controller/src/types.ts
index 2cb544a89..815d044a6 100644
--- a/packages/controller/src/types.ts
+++ b/packages/controller/src/types.ts
@@ -101,6 +101,36 @@ export type DeployReply = {
transaction_hash: string;
};
+export type LocationCoordinates = {
+ latitude: number;
+ longitude: number;
+ accuracy: number;
+ altitude?: number | null;
+ altitudeAccuracy?: number | null;
+ heading?: number | null;
+ speed?: number | null;
+ timestamp: number;
+};
+
+export type LocationPromptReply = {
+ code: ResponseCodes.SUCCESS;
+ location: LocationCoordinates;
+};
+
+export type LocationGateOptions = {
+ /** ISO 3166-1 alpha-2 country codes, e.g. "US", "CA". */
+ allowedCountries?: string[];
+ /** ISO 3166-2 region codes, e.g. "US-CA". */
+ allowedRegions?: string[];
+ /** US state abbreviations or full names, e.g. "CA" or "California". */
+ allowedStates?: string[];
+};
+
+export type ConnectOptions = {
+ signupOptions?: AuthOptions;
+ locationGate?: LocationGateOptions;
+};
+
export type IFrames = {
keychain?: KeychainIFrame;
version?: number;
@@ -131,7 +161,9 @@ export type ControllerAccounts = Record;
export interface Keychain {
probe(rpcUrl: string): Promise;
- connect(signupOptions?: AuthOptions): Promise;
+ connect(
+ options?: AuthOptions | ConnectOptions,
+ ): Promise;
disconnect(): void;
reset(): void;
@@ -160,6 +192,9 @@ export interface Keychain {
username(): string;
openPurchaseCredits(): void;
openExecute(calls: Call[]): Promise;
+ openLocationPrompt(
+ options?: LocationPromptOptions,
+ ): Promise;
switchChain(rpcUrl: string): Promise;
openStarterPack(
id: string | number,
@@ -239,6 +274,8 @@ export type KeychainOptions = IFrameOptions & {
feeSource?: FeeSource;
/** Signup options (the order of the options is reflected in the UI. It's recommended to group socials and wallets together ) */
signupOptions?: AuthOptions;
+ /** Optional location gating to enforce allowed regions before connect. */
+ locationGate?: LocationGateOptions;
/** When true, manually provided policies will override preset policies. Default is false. */
shouldOverridePresetPolicies?: boolean;
/** The project name of Slot instance. */
@@ -270,6 +307,11 @@ export type OpenOptions = {
redirectUrl?: string;
};
+export type LocationPromptOptions = {
+ /** Optional path to navigate to after completion (standalone mode). */
+ returnTo?: string;
+};
+
export type StarterpackOptions = {
/** The preimage to use */
preimage?: string;
diff --git a/packages/keychain/src/components/app.tsx b/packages/keychain/src/components/app.tsx
index 2674474e1..adacd65f8 100644
--- a/packages/keychain/src/components/app.tsx
+++ b/packages/keychain/src/components/app.tsx
@@ -39,6 +39,8 @@ import { CollectibleListing } from "./inventory/collection/collectible-listing";
import { CollectiblePurchase } from "./inventory/collection/collectible-purchase";
import { Execute } from "./Execute";
import { SignMessage } from "./SignMessage";
+import { LocationGate } from "./location/LocationGate";
+import { LocationPrompt } from "./location/LocationPrompt";
import { ConnectRoute } from "./ConnectRoute";
import { Funding } from "./funding";
import { Deposit } from "./funding/Deposit";
@@ -290,6 +292,8 @@ export function App() {
/>
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/packages/keychain/src/components/location/LocationGate.tsx b/packages/keychain/src/components/location/LocationGate.tsx
new file mode 100644
index 000000000..47c762a4c
--- /dev/null
+++ b/packages/keychain/src/components/location/LocationGate.tsx
@@ -0,0 +1,187 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+import {
+ Button,
+ GlobeIcon,
+ HeaderInner,
+ LayoutContent,
+ LayoutFooter,
+} from "@cartridge/ui";
+import { LocationGateOptions, ResponseCodes } from "@cartridge/controller";
+import { ErrorAlert } from "@/components/ErrorAlert";
+import { useNavigation } from "@/context";
+import { useConnection } from "@/hooks/connection";
+import { cleanupCallbacks, getCallbacks } from "@/utils/connection/callbacks";
+import {
+ evaluateLocationGate,
+ reverseGeocodeLocation,
+} from "@/utils/location-gate";
+
+type GateState = "idle" | "requesting";
+
+const CANCEL_RESPONSE = {
+ code: ResponseCodes.CANCELED,
+ message: "Canceled",
+};
+
+const ERROR_RESPONSE = {
+ code: ResponseCodes.ERROR,
+ message: "This game is not available in your region.",
+};
+
+export function LocationGate() {
+ const { setShowClose } = useNavigation();
+ const { closeModal } = useConnection();
+ const { search } = useLocation();
+ const navigate = useNavigate();
+ const [state, setState] = useState("idle");
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setShowClose(true);
+ }, [setShowClose]);
+
+ const { returnTo, gate } = useMemo(() => {
+ const searchParams = new URLSearchParams(search);
+ const returnToParam = searchParams.get("returnTo");
+ const gateParam = searchParams.get("gate");
+
+ let parsedGate: LocationGateOptions | null = null;
+ if (gateParam) {
+ try {
+ parsedGate = JSON.parse(gateParam) as LocationGateOptions;
+ } catch (parseError) {
+ console.error("Failed to parse location gate params:", parseError);
+ }
+ }
+
+ return { returnTo: returnToParam, gate: parsedGate };
+ }, [search]);
+
+ const connectId = useMemo(() => {
+ if (!returnTo) {
+ return null;
+ }
+ try {
+ const url = new URL(returnTo, window.location.origin);
+ return url.searchParams.get("id");
+ } catch (err) {
+ console.error("Failed to parse returnTo:", err);
+ return null;
+ }
+ }, [returnTo]);
+
+ const resolveConnect = useCallback(
+ (response: { code: ResponseCodes; message: string }) => {
+ if (connectId) {
+ const callbacks = getCallbacks(connectId);
+ callbacks?.resolve?.(response);
+ cleanupCallbacks(connectId);
+ }
+ closeModal?.();
+ },
+ [connectId, closeModal],
+ );
+
+ const handleCancel = useCallback(() => {
+ resolveConnect(CANCEL_RESPONSE);
+ }, [resolveConnect]);
+
+ const handleContinue = useCallback(() => {
+ if (!gate || !returnTo) {
+ setError("Location requirements are missing.");
+ return;
+ }
+
+ if (typeof navigator === "undefined" || !navigator.geolocation) {
+ setError("Location services are not available in this browser.");
+ return;
+ }
+
+ setError(null);
+ setState("requesting");
+
+ navigator.geolocation.getCurrentPosition(
+ async (position) => {
+ try {
+ const { coords, timestamp } = position;
+ const geo = await reverseGeocodeLocation({
+ latitude: coords.latitude,
+ longitude: coords.longitude,
+ accuracy: coords.accuracy,
+ altitude: coords.altitude,
+ altitudeAccuracy: coords.altitudeAccuracy,
+ heading: coords.heading,
+ speed: coords.speed,
+ timestamp,
+ });
+
+ const gateResult = evaluateLocationGate({ gate, geo });
+
+ if (!gateResult.allowed) {
+ resolveConnect(ERROR_RESPONSE);
+ return;
+ }
+
+ navigate(returnTo, { replace: true });
+ } catch (geoError) {
+ console.error("Location gate failed:", geoError);
+ setState("idle");
+ setError("Unable to verify location.");
+ }
+ },
+ (geoError) => {
+ setState("idle");
+ if (geoError?.code === 1) {
+ setError("Location permission was denied.");
+ return;
+ }
+ setError(geoError?.message || "Unable to verify location.");
+ },
+ {
+ enableHighAccuracy: true,
+ timeout: 15000,
+ maximumAge: 60000,
+ },
+ );
+ }, [gate, navigate, returnTo, resolveConnect]);
+
+ if (!gate) {
+ return null;
+ }
+
+ return (
+ <>
+ }
+ />
+
+
+ This game needs your location to confirm availability in your region.
+
+
+
+ {error && (
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/packages/keychain/src/components/location/LocationPrompt.tsx b/packages/keychain/src/components/location/LocationPrompt.tsx
new file mode 100644
index 000000000..c80140c34
--- /dev/null
+++ b/packages/keychain/src/components/location/LocationPrompt.tsx
@@ -0,0 +1,132 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ Button,
+ GlobeIcon,
+ HeaderInner,
+ LayoutContent,
+ LayoutFooter,
+} from "@cartridge/ui";
+import { ResponseCodes } from "@cartridge/controller";
+import { ErrorAlert } from "@/components/ErrorAlert";
+import {
+ useRouteCallbacks,
+ useRouteCompletion,
+ useRouteParams,
+} from "@/hooks/route";
+import { cleanupCallbacks } from "@/utils/connection/callbacks";
+import { parseLocationPromptParams } from "@/utils/connection/location";
+import { useNavigation } from "@/context";
+
+const CANCEL_RESPONSE = {
+ code: ResponseCodes.CANCELED,
+ message: "Canceled",
+};
+
+type LocationState = "idle" | "requesting";
+
+export function LocationPrompt() {
+ const params = useRouteParams(parseLocationPromptParams);
+ const handleCompletion = useRouteCompletion();
+ const { cancelWithoutClosing } = useRouteCallbacks(params, CANCEL_RESPONSE);
+ const { setShowClose } = useNavigation();
+ const [state, setState] = useState("idle");
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setShowClose(true);
+ }, [setShowClose]);
+
+ const handleContinue = useCallback(() => {
+ if (!params) {
+ return;
+ }
+
+ if (typeof navigator === "undefined" || !navigator.geolocation) {
+ setError("Location services are not available in this browser.");
+ return;
+ }
+
+ setError(null);
+ setState("requesting");
+
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ const { coords, timestamp } = position;
+ params.resolve?.({
+ code: ResponseCodes.SUCCESS,
+ location: {
+ latitude: coords.latitude,
+ longitude: coords.longitude,
+ accuracy: coords.accuracy,
+ altitude: coords.altitude,
+ altitudeAccuracy: coords.altitudeAccuracy,
+ heading: coords.heading,
+ speed: coords.speed,
+ timestamp,
+ },
+ });
+
+ cleanupCallbacks(params.params.id);
+ handleCompletion();
+ },
+ (geoError) => {
+ setState("idle");
+ if (geoError?.code === 1) {
+ setError("Location permission was denied.");
+ return;
+ }
+ setError(geoError?.message || "Unable to verify location.");
+ },
+ {
+ enableHighAccuracy: true,
+ timeout: 15000,
+ maximumAge: 60000,
+ },
+ );
+ }, [params, handleCompletion]);
+
+ const handleCancel = useCallback(() => {
+ cancelWithoutClosing();
+ handleCompletion();
+ }, [cancelWithoutClosing, handleCompletion]);
+
+ if (!params) {
+ return null;
+ }
+
+ return (
+ <>
+ }
+ />
+
+
+ This game needs your location to verify eligibility. We'll share your
+ location with the game to complete verification.
+
+
+
+ {error && (
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/packages/keychain/src/utils/connection/connect.ts b/packages/keychain/src/utils/connection/connect.ts
index f04f233ec..f7bc248d4 100644
--- a/packages/keychain/src/utils/connection/connect.ts
+++ b/packages/keychain/src/utils/connection/connect.ts
@@ -1,6 +1,13 @@
-import { AuthOptions, ConnectError, ConnectReply } from "@cartridge/controller";
+import {
+ AuthOptions,
+ ConnectError,
+ ConnectOptions,
+ ConnectReply,
+ LocationGateOptions,
+} from "@cartridge/controller";
import { SessionPolicies } from "@cartridge/presets";
import { generateCallbackId, storeCallbacks, getCallbacks } from "./callbacks";
+import { createLocationGateUrl } from "./location-gate";
export interface ConnectParams {
id: string;
@@ -8,6 +15,7 @@ export interface ConnectParams {
policies: SessionPolicies | undefined;
rpcUrl: string;
signupOptions?: AuthOptions;
+ locationGate?: LocationGateOptions;
}
type ConnectCallback = {
@@ -29,7 +37,7 @@ function isConnectResult(value: unknown): value is ConnectReply | ConnectError {
export function createConnectUrl(
signupOptions?: AuthOptions,
options: ConnectCallback = {},
-): string {
+): { url: string; id: string } {
const id = generateCallbackId();
if (options.resolve || options.reject || options.onCancel) {
@@ -49,7 +57,7 @@ export function createConnectUrl(
params.set("signers", JSON.stringify(signupOptions));
}
- return `/connect?${params.toString()}`;
+ return { url: `/connect?${params.toString()}`, id };
}
export function parseConnectParams(searchParams: URLSearchParams): {
@@ -132,31 +140,35 @@ export function connect({
// Old: connect(policies: SessionPolicies, rpcUrl: string, signupOptions?: AuthOptions)
// New: connect(signupOptions?: AuthOptions)
return (
- policiesOrSigners?: SessionPolicies | AuthOptions,
+ policiesOrSigners?: SessionPolicies | AuthOptions | ConnectOptions,
rpcUrl?: string,
signupOptions?: AuthOptions,
): Promise => {
- let signers: AuthOptions | undefined;
+ let options: ConnectOptions = {};
// Detect which signature is being used
if (rpcUrl !== undefined) {
// Old signature: connect(policies, rpcUrl, signupOptions)
// In the old signature, the first arg is policies (not used in new flow)
// and the third arg is signupOptions
- signers = signupOptions;
+ options.signupOptions = signupOptions;
// Set the RPC URL for backwards compatibility
setRpcUrl(rpcUrl);
} else {
// New signature: connect(signupOptions)
- signers = policiesOrSigners as AuthOptions | undefined;
+ if (Array.isArray(policiesOrSigners)) {
+ options.signupOptions = policiesOrSigners;
+ } else if (policiesOrSigners && typeof policiesOrSigners === "object") {
+ options = policiesOrSigners as ConnectOptions;
+ }
}
- if (signers && signers.length === 0) {
+ if (options.signupOptions && options.signupOptions.length === 0) {
throw new Error("If defined, signup options cannot be empty");
}
return new Promise((resolve, reject) => {
- const url = createConnectUrl(signers, {
+ const { url } = createConnectUrl(options.signupOptions, {
resolve: (result) => {
if ("address" in result) {
resolve(result);
@@ -167,7 +179,20 @@ export function connect({
reject,
});
- navigate(url, { replace: true });
+ const hasLocationGate =
+ !!options.locationGate &&
+ ((options.locationGate.allowedCountries?.length ?? 0) > 0 ||
+ (options.locationGate.allowedRegions?.length ?? 0) > 0 ||
+ (options.locationGate.allowedStates?.length ?? 0) > 0);
+
+ const destination = hasLocationGate
+ ? createLocationGateUrl({
+ returnTo: url,
+ gate: options.locationGate!,
+ })
+ : url;
+
+ navigate(destination, { replace: true });
});
};
};
diff --git a/packages/keychain/src/utils/connection/index.ts b/packages/keychain/src/utils/connection/index.ts
index 04e2ab192..989442697 100644
--- a/packages/keychain/src/utils/connection/index.ts
+++ b/packages/keychain/src/utils/connection/index.ts
@@ -11,6 +11,7 @@ import { signMessageFactory } from "./sign";
import { switchChain } from "./switchChain";
import { navigateFactory } from "./navigate";
import { StarterpackOptions } from "@cartridge/controller";
+import { locationPromptFactory } from "./location";
export type { ControllerError } from "./execute";
@@ -78,6 +79,7 @@ export function connectToController({
{ replace: true },
);
},
+ openLocationPrompt: () => locationPromptFactory({ navigate }),
switchChain: () => switchChain({ setController, setRpcUrl }),
},
});
diff --git a/packages/keychain/src/utils/connection/location-gate.ts b/packages/keychain/src/utils/connection/location-gate.ts
new file mode 100644
index 000000000..bc7e8819c
--- /dev/null
+++ b/packages/keychain/src/utils/connection/location-gate.ts
@@ -0,0 +1,14 @@
+import { LocationGateOptions } from "@cartridge/controller";
+
+export function createLocationGateUrl({
+ returnTo,
+ gate,
+}: {
+ returnTo: string;
+ gate: LocationGateOptions;
+}) {
+ const params = new URLSearchParams();
+ params.set("returnTo", returnTo);
+ params.set("gate", JSON.stringify(gate));
+ return `/location-gate?${params.toString()}`;
+}
diff --git a/packages/keychain/src/utils/connection/location.ts b/packages/keychain/src/utils/connection/location.ts
new file mode 100644
index 000000000..7042e5c1d
--- /dev/null
+++ b/packages/keychain/src/utils/connection/location.ts
@@ -0,0 +1,132 @@
+import {
+ ConnectError,
+ LocationPromptReply,
+ LocationPromptOptions,
+} from "@cartridge/controller";
+import { generateCallbackId, getCallbacks, storeCallbacks } from "./callbacks";
+
+export type LocationPromptParams = {
+ id: string;
+};
+
+type LocationPromptCallback = {
+ resolve?: (result: LocationPromptReply | ConnectError) => void;
+ reject?: (reason?: unknown) => void;
+ onCancel?: () => void;
+};
+
+function isLocationPromptResult(
+ value: unknown,
+): value is LocationPromptReply | ConnectError {
+ if (!value || typeof value !== "object") {
+ return false;
+ }
+ const obj = value as Record;
+ return (
+ typeof obj.code === "string" && ("message" in obj || "location" in obj)
+ );
+}
+
+export function createLocationPromptUrl(
+ options: LocationPromptCallback & LocationPromptOptions = {},
+): string {
+ const id = generateCallbackId();
+
+ if (options.resolve || options.reject || options.onCancel) {
+ storeCallbacks(id, {
+ resolve: options.resolve
+ ? (result) => {
+ if (!isLocationPromptResult(result)) {
+ const error = new Error("Invalid location prompt result type");
+ console.error(error.message, result);
+ options.reject?.(error);
+ return;
+ }
+ options.resolve?.(result);
+ }
+ : undefined,
+ reject: options.reject,
+ onCancel: options.onCancel,
+ });
+ }
+
+ let url = `/location?id=${encodeURIComponent(id)}`;
+
+ if (options.returnTo) {
+ url += `&returnTo=${encodeURIComponent(options.returnTo)}`;
+ }
+
+ return url;
+}
+
+export function parseLocationPromptParams(searchParams: URLSearchParams): {
+ params: LocationPromptParams;
+ resolve?: (result: unknown) => void;
+ reject?: (reason?: unknown) => void;
+ onCancel?: () => void;
+} | null {
+ try {
+ const id = searchParams.get("id");
+
+ if (!id) {
+ console.error("Missing required parameters");
+ return null;
+ }
+
+ const callbacks = getCallbacks(id) as LocationPromptCallback | undefined;
+
+ const reject = callbacks?.reject
+ ? (reason?: unknown) => {
+ callbacks.reject?.(reason);
+ }
+ : undefined;
+
+ const resolve = callbacks?.resolve
+ ? (value: unknown) => {
+ if (!isLocationPromptResult(value)) {
+ const error = new Error("Invalid location prompt result type");
+ console.error(error.message, value);
+ reject?.(error);
+ return;
+ }
+ callbacks.resolve?.(value);
+ }
+ : undefined;
+
+ const onCancel = callbacks?.onCancel
+ ? () => {
+ callbacks.onCancel?.();
+ }
+ : undefined;
+
+ return {
+ params: { id },
+ resolve,
+ reject,
+ onCancel,
+ };
+ } catch (error) {
+ console.error("Failed to parse location prompt params:", error);
+ return null;
+ }
+}
+
+export function locationPromptFactory({
+ navigate,
+}: {
+ navigate: (
+ to: string | number,
+ options?: { replace?: boolean; state?: unknown },
+ ) => void;
+}) {
+ return (options?: LocationPromptOptions) =>
+ new Promise((resolve, reject) => {
+ const url = createLocationPromptUrl({
+ resolve,
+ reject,
+ returnTo: options?.returnTo,
+ });
+
+ navigate(url, { replace: true });
+ });
+}
diff --git a/packages/keychain/src/utils/location-gate.ts b/packages/keychain/src/utils/location-gate.ts
new file mode 100644
index 000000000..35cdd1e02
--- /dev/null
+++ b/packages/keychain/src/utils/location-gate.ts
@@ -0,0 +1,188 @@
+import type {
+ LocationCoordinates,
+ LocationGateOptions,
+} from "@cartridge/controller";
+
+type GeocodeResult = {
+ countryCode?: string | null;
+ regionCode?: string | null;
+ regionName?: string | null;
+};
+
+const US_STATE_NAME_TO_CODE: Record = {
+ alabama: "AL",
+ alaska: "AK",
+ arizona: "AZ",
+ arkansas: "AR",
+ california: "CA",
+ colorado: "CO",
+ connecticut: "CT",
+ delaware: "DE",
+ florida: "FL",
+ georgia: "GA",
+ hawaii: "HI",
+ idaho: "ID",
+ illinois: "IL",
+ indiana: "IN",
+ iowa: "IA",
+ kansas: "KS",
+ kentucky: "KY",
+ louisiana: "LA",
+ maine: "ME",
+ maryland: "MD",
+ massachusetts: "MA",
+ michigan: "MI",
+ minnesota: "MN",
+ mississippi: "MS",
+ missouri: "MO",
+ montana: "MT",
+ nebraska: "NE",
+ nevada: "NV",
+ "new hampshire": "NH",
+ "new jersey": "NJ",
+ "new mexico": "NM",
+ "new york": "NY",
+ "north carolina": "NC",
+ "north dakota": "ND",
+ ohio: "OH",
+ oklahoma: "OK",
+ oregon: "OR",
+ pennsylvania: "PA",
+ "rhode island": "RI",
+ "south carolina": "SC",
+ "south dakota": "SD",
+ tennessee: "TN",
+ texas: "TX",
+ utah: "UT",
+ vermont: "VT",
+ virginia: "VA",
+ washington: "WA",
+ "west virginia": "WV",
+ wisconsin: "WI",
+ wyoming: "WY",
+ "district of columbia": "DC",
+};
+
+const normalizeCode = (value?: string | null) =>
+ value ? value.trim().toUpperCase() : undefined;
+
+const normalizeState = (value: string) => {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+ if (trimmed.length === 2) {
+ return trimmed.toUpperCase();
+ }
+ return US_STATE_NAME_TO_CODE[trimmed.toLowerCase()];
+};
+
+const normalizeGateOptions = (options: LocationGateOptions) => {
+ const allowedCountries = new Set(
+ (options.allowedCountries ?? [])
+ .map((code) => normalizeCode(code))
+ .filter((code): code is string => !!code),
+ );
+ const allowedRegions = new Set(
+ (options.allowedRegions ?? [])
+ .map((code) => normalizeCode(code))
+ .filter((code): code is string => !!code),
+ );
+ const allowedStates = new Set(
+ (options.allowedStates ?? [])
+ .map((code) => normalizeState(code))
+ .filter((code): code is string => !!code),
+ );
+
+ return { allowedCountries, allowedRegions, allowedStates };
+};
+
+export async function reverseGeocodeLocation(
+ coords: LocationCoordinates,
+): Promise {
+ const url = new URL(
+ "https://api.bigdatacloud.net/data/reverse-geocode-client",
+ );
+ url.searchParams.set("latitude", coords.latitude.toString());
+ url.searchParams.set("longitude", coords.longitude.toString());
+ url.searchParams.set("localityLanguage", "en");
+
+ const response = await fetch(url.toString());
+ if (!response.ok) {
+ throw new Error("Failed to resolve location");
+ }
+
+ const data = (await response.json()) as {
+ countryCode?: string;
+ principalSubdivision?: string;
+ principalSubdivisionCode?: string;
+ };
+
+ return {
+ countryCode: data.countryCode ?? null,
+ regionCode: data.principalSubdivisionCode ?? null,
+ regionName: data.principalSubdivision ?? null,
+ };
+}
+
+export function evaluateLocationGate({
+ gate,
+ geo,
+}: {
+ gate: LocationGateOptions;
+ geo: GeocodeResult;
+}) {
+ const { allowedCountries, allowedRegions, allowedStates } =
+ normalizeGateOptions(gate);
+
+ if (
+ allowedCountries.size === 0 &&
+ allowedRegions.size === 0 &&
+ allowedStates.size === 0
+ ) {
+ return { allowed: true };
+ }
+
+ const countryCode = normalizeCode(geo.countryCode);
+ const regionCode = normalizeCode(geo.regionCode);
+ const regionName = geo.regionName;
+
+ if (allowedRegions.size > 0 || allowedStates.size > 0) {
+ let allowed = false;
+ let stateCode: string | undefined;
+
+ if (regionCode) {
+ if (allowedRegions.has(regionCode)) {
+ allowed = true;
+ } else {
+ const parts = regionCode.split("-");
+ if (parts.length > 1) {
+ stateCode = parts[parts.length - 1];
+ }
+ }
+ }
+
+ if (!allowed && regionName) {
+ stateCode = stateCode ?? normalizeState(regionName);
+ }
+
+ if (!allowed && stateCode && allowedStates.has(stateCode)) {
+ allowed = true;
+ }
+
+ return {
+ allowed,
+ reason: allowed ? undefined : "Location not eligible",
+ };
+ }
+
+ if (allowedCountries.size > 0) {
+ const allowed = !!countryCode && allowedCountries.has(countryCode);
+ return {
+ allowed,
+ reason: allowed ? undefined : "Location not eligible",
+ };
+ }
+
+ return { allowed: true };
+}