diff --git a/.claude/settings.json b/.claude/settings.json index b0804e81ad..c848068388 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -17,6 +17,7 @@ "Bash(pnpm:storybook:*)", "Bash(pnpm:e2e:*)", "Bash(pnpm:--filter:*)", + "Bash(gh:pr:*)", "Bash(git:status)", "Bash(git:diff)", "Bash(git:log)", diff --git a/.gitignore b/.gitignore index 07b4348780..473ffad40a 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +**/coverage/ *.cover *.py,cover .hypothesis/ diff --git a/examples/next/src/components/Header.tsx b/examples/next/src/components/Header.tsx index b712eb976e..c1543ae2f5 100644 --- a/examples/next/src/components/Header.tsx +++ b/examples/next/src/components/Header.tsx @@ -13,6 +13,9 @@ import { useState, useEffect, useRef, useMemo } from "react"; import { constants, num, shortString } from "starknet"; import { Chain } from "@starknet-react/chains"; import SessionConnector from "@cartridge/connector/session"; +import { HeadlessLogin } from "components/HeadlessLogin"; + +type HeadlessModalState = "closed" | "open" | "hidden"; const Header = () => { const { connect, connectors } = useConnect(); @@ -21,6 +24,8 @@ const Header = () => { const { address, status } = useAccount(); const [networkOpen, setNetworkOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false); + const [headlessState, setHeadlessState] = + useState("closed"); const [isControllerReady, setIsControllerReady] = useState(false); const { switchChain } = useSwitchChain({ params: { @@ -188,6 +193,12 @@ const Header = () => { > Standalone + + setHeadlessState("hidden")} + onDone={() => setHeadlessState("closed")} + onError={() => setHeadlessState("open")} + /> + + + )} ); }; diff --git a/examples/next/src/components/HeadlessLogin.tsx b/examples/next/src/components/HeadlessLogin.tsx new file mode 100644 index 0000000000..05a19c3654 --- /dev/null +++ b/examples/next/src/components/HeadlessLogin.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useConnect } from "@starknet-react/core"; +import { useState } from "react"; +import { controllerConnector } from "./providers/StarknetProvider"; + +type AuthMethod = "passkey" | "metamask"; + +interface EthereumProvider { + request: (args: { method: string; params?: unknown[] }) => Promise; + isMetaMask?: boolean; +} + +export function HeadlessLogin({ + onStart, + onDone, + onError, +}: { + onStart?: () => void; + onDone?: () => void; + onError?: () => void; +}) { + const { connectAsync } = useConnect(); + const [username, setUsername] = useState(""); + const [loading, setLoading] = useState(null); + const [result, setResult] = useState<{ + success: boolean; + message: string; + address?: string; + } | null>(null); + const prepareHeadlessConnect = async () => { + const controller = controllerConnector.controller; + // Ensure we don't short-circuit on an existing account. + if (controller.account) { + await controller.disconnect(); + } + return controller; + }; + + const handlePasskeyLogin = async () => { + if (!username) { + setResult({ + success: false, + message: "Please provide a username", + }); + return; + } + + onStart?.(); + setLoading("passkey"); + setResult(null); + + try { + const controller = await prepareHeadlessConnect(); + const account = await controller.connect({ + username, + signer: "webauthn", + }); + if (!account) { + throw new Error("Failed to connect"); + } + + // Sync starknet-react state so the header/app reflect the new connection. + await connectAsync({ connector: controllerConnector }); + + setResult({ + success: true, + message: "Successfully authenticated with Passkey!", + address: account.address, + }); + onDone?.(); + } catch (error: unknown) { + setResult({ + success: false, + message: (error as Error)?.message || "Passkey authentication failed", + }); + onError?.(); + } finally { + setLoading(null); + } + }; + + const handleMetaMaskLogin = async () => { + if (!username) { + setResult({ + success: false, + message: "Please provide a username", + }); + return; + } + + onStart?.(); + setLoading("metamask"); + setResult(null); + + try { + // Check if MetaMask is installed + const ethereum = (window as { ethereum?: EthereumProvider }).ethereum; + if (!ethereum) { + setResult({ + success: false, + message: "MetaMask is not installed", + }); + onError?.(); + return; + } + + const controller = await prepareHeadlessConnect(); + const account = await controller.connect({ + username, + signer: "metamask", + }); + if (!account) { + throw new Error("Failed to connect"); + } + + // Sync starknet-react state so the header/app reflect the new connection. + await connectAsync({ connector: controllerConnector }); + + setResult({ + success: true, + message: "Successfully authenticated with MetaMask!", + address: account.address, + }); + onDone?.(); + } catch (error: unknown) { + setResult({ + success: false, + message: (error as Error)?.message || "MetaMask authentication failed", + }); + onError?.(); + } finally { + setLoading(null); + } + }; + + return ( +
+

+ Headless Login +

+ +

+ Test programmatic authentication without UI. This demonstrates the + headless mode feature for automated authentication with Passkey or + MetaMask. +

+ +
+
+ + setUsername(e.target.value)} + placeholder="Enter username" + className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-500" + disabled={loading !== null} + /> +
+ +
+ + + +
+
+ + {result && ( +
+

+ {result.success ? "✓ Success" : "✗ Error"} +

+

{result.message}

+ {result.address && ( +

+ Address: {result.address} +

+ )} +
+ )} + +
+

+ Passkey Authentication: Uses the passkey signer + already registered for this username. Make sure the account has a + WebAuthn signer before testing. +

+

+ MetaMask Authentication: Requires MetaMask browser + extension to be installed. Will prompt for account connection when + clicked. +

+

+ Note: Headless mode auto-submits login. With no + policies, sign-in completes without session approval UI. If policies + are unverified or include approvals, Keychain will prompt for session + approval after authentication; verified policies auto-create the + session. Use connect({"{ username, signer }"}) and pass{" "} + password when using the password signer. +

+
+
+ ); +} diff --git a/examples/next/src/components/Starterpack.tsx b/examples/next/src/components/Starterpack.tsx index 5d6bc23378..5c97248b1d 100644 --- a/examples/next/src/components/Starterpack.tsx +++ b/examples/next/src/components/Starterpack.tsx @@ -3,7 +3,7 @@ import { useAccount, useNetwork } from "@starknet-react/core"; import ControllerConnector from "@cartridge/connector/controller"; import { Button, Input } from "@cartridge/ui"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import { constants, num } from "starknet"; export const Starterpack = () => { @@ -12,7 +12,7 @@ export const Starterpack = () => { const controllerConnector = connector as unknown as ControllerConnector; - const getDefaultStarterpackIds = () => { + const getDefaultStarterpackIds = useCallback(() => { if (chain && num.toHex(chain.id) === constants.StarknetChainId.SN_MAIN) { return { purchaseOnchain: 0, @@ -23,9 +23,12 @@ export const Starterpack = () => { purchaseOnchain: 0, claim: "claim-dopewars-sepolia", }; - }; + }, [chain]); - const defaultIds = getDefaultStarterpackIds(); + const defaultIds = useMemo( + () => getDefaultStarterpackIds(), + [getDefaultStarterpackIds], + ); const [claimSpId, setClaimSpId] = useState(defaultIds.claim); const [claimPreimage, setClaimPreimage] = useState(""); const [purchaseOnchainSpId, setPurchaseOnchainSpId] = useState( @@ -59,7 +62,7 @@ export const Starterpack = () => { // Update our references after successful comparison and update expectedDefaultsRef.current = newDefaults; previousChainRef.current = chain; - }, [chain]); + }, [chain, getDefaultStarterpackIds]); if (!account) { return null; diff --git a/examples/next/src/components/providers/StarknetProvider.tsx b/examples/next/src/components/providers/StarknetProvider.tsx index d09713cae6..06c631a52b 100644 --- a/examples/next/src/components/providers/StarknetProvider.tsx +++ b/examples/next/src/components/providers/StarknetProvider.tsx @@ -195,8 +195,7 @@ const signupOptions: AuthOptions = [ "phantom-evm", ]; -const controller = new ControllerConnector({ - policies, +export const controllerConnector = new ControllerConnector({ // With the defaults, you can omit chains if you want to use: // - chains: [ // { rpcUrl: "https://api.cartridge.gg/x/starknet/sepolia/rpc/v0_9" }, @@ -210,18 +209,19 @@ const controller = new ControllerConnector({ // By default, preset policies take precedence over manually provided policies // Set shouldOverridePresetPolicies to true if you want your policies to override preset // shouldOverridePresetPolicies: true, + policies, tokens: { erc20: ["lords", "strk"], }, // nums (achievements, quests) - slot: "nums-bal", - namespace: "NUMS", - preset: "nums", + // slot: "nums-bal", + // namespace: "NUMS", + // preset: "nums", // Pistols (achievements, no quests) - // slot: "arcade-pistols", - // namespace: "pistols", + slot: "arcade-pistols", + namespace: "pistols", // preset: "pistols", // Loot Survivor (no achievements, no quests) @@ -247,7 +247,7 @@ export function StarknetProvider({ children }: PropsWithChildren) { autoConnect defaultChainId={mainnet.id} chains={starknetConfigChains} - connectors={[controller, session]} + connectors={[controllerConnector, session]} explorer={cartridge} provider={provider} > diff --git a/package.json b/package.json index a850077363..a0e82243b4 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "example:svelte": "pnpm --filter @cartridge/controller-example-svelte", "graphql:gen": "graphql-codegen --config packages/keychain/src/utils/api/codegen.yaml", "test": "pnpm keychain test", - "test:ci": "pnpm keychain test:ci", + "test:ci": "pnpm keychain test:ci --coverage && pnpm controller test && pnpm connector test:ci", "test:storybook": "pnpm turbo build:deps test:storybook", "test:storybook:update": "pnpm turbo build:deps test:storybook:update", "check:regression": "pnpm ./scripts/check_regression.sh", diff --git a/packages/connector/package.json b/packages/connector/package.json index 85f2ac470d..a2f68d24df 100644 --- a/packages/connector/package.json +++ b/packages/connector/package.json @@ -13,6 +13,8 @@ "sideEffects": false, "scripts": { "build:deps": "tsup --dts-resolve", + "test": "vitest", + "test:ci": "vitest run", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"" }, @@ -53,8 +55,10 @@ }, "devDependencies": { "@cartridge/tsconfig": "workspace:*", + "@vitest/coverage-v8": "2.1.8", "prettier": "catalog:", "tsup": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "2.1.8" } } diff --git a/packages/connector/src/__tests__/controller.test.ts b/packages/connector/src/__tests__/controller.test.ts new file mode 100644 index 0000000000..653c7cf1bf --- /dev/null +++ b/packages/connector/src/__tests__/controller.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock starknet-react's InjectedConnector so we can control what super.connect returns. +vi.mock("@starknet-react/core", () => { + type InjectedConnectorOptions = { id: string; name?: string }; + + class InjectedConnector { + private readonly _options: InjectedConnectorOptions; + + constructor({ options }: { options: InjectedConnectorOptions }) { + this._options = options; + } + + get id(): string { + return this._options.id; + } + + get name(): string { + return this._options.name ?? this._options.id; + } + + // Simulate an edge case where the injected request resolves but returns an + // empty/undefined account. Our connector should still return the address + // from controller.connect(). + async connect(): Promise<{ account?: string; chainId?: bigint }> { + return { account: undefined, chainId: 1n }; + } + + async disconnect(): Promise {} + } + + // The real module exports both, but ControllerConnector only needs InjectedConnector. + class Connector {} + + return { Connector, InjectedConnector }; +}); + +// Mock controller SDK so ControllerConnector doesn't need a real iframe/keychain. +vi.mock("@cartridge/controller", () => { + class ControllerProvider { + id = "controller"; + name = "Controller"; + account?: { address: string }; + + constructor() { + if (typeof window !== "undefined") { + (window as any).starknet_controller = this; + } + } + + async connect(): Promise<{ address: string } | undefined> { + this.account = { address: "0xabc" }; + return this.account; + } + + disconnect() {} + username() {} + isReady() { + return true; + } + delegateAccount() {} + asWalletStandard() {} + } + + return { + __esModule: true, + default: ControllerProvider, + }; +}); + +import ControllerConnector from "../controller"; + +describe("ControllerConnector", () => { + beforeEach(() => { + (globalThis as any).window = {}; + }); + + afterEach(() => { + delete (globalThis as any).window; + }); + + it("returns the address from controller.connect() even if injected connect has no account", async () => { + const connector = new ControllerConnector(); + + const result = await connector.connect(); + + expect(result.account).toBe("0xabc"); + expect((window as any).starknet_controller).toBe(connector.controller); + }); +}); diff --git a/packages/connector/src/controller.ts b/packages/connector/src/controller.ts index 510a2720c1..8a57635f07 100644 --- a/packages/connector/src/controller.ts +++ b/packages/connector/src/controller.ts @@ -1,5 +1,5 @@ import ControllerProvider, { - AuthOptions, + ConnectOptions, ControllerOptions, } from "@cartridge/controller"; import { Connector, InjectedConnector } from "@starknet-react/core"; @@ -26,7 +26,12 @@ export default class ControllerConnector extends InjectedConnector { } async disconnect() { - this.controller.disconnect(); + await this.controller.disconnect(); + try { + await super.disconnect(); + } catch { + // Best-effort: starknet-react may call disconnect even when the injected wallet isn't present. + } } username() { @@ -41,12 +46,30 @@ 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 } & ConnectOptions) { + const { chainIdHint, ...connectOptions } = args ?? {}; + const controllerArgs = + args && Object.keys(connectOptions).length > 0 + ? (connectOptions as ConnectOptions) + : undefined; + + const account = await this.controller.connect(controllerArgs); if (!account) { throw new Error("Failed to connect controller"); } - return super.connect({ chainIdHint: args?.chainIdHint }); + + // Ensure the injected wallet instance used by starknet-react (window.starknet_controller) + // always points at the same ControllerProvider instance this connector wraps. + if (typeof window !== "undefined") { + (window as any).starknet_controller = this.controller; + } + + const data = await super.connect({ chainIdHint }); + + // `@starknet-react/core` updates its state from the `account` returned here. + // Use the authoritative address from `controller.connect()` to avoid edge cases + // where the injected wallet request returns an empty/undefined account. + return { ...data, account: account.address }; } static fromConnectors(connectors: Connector[]): ControllerConnector { diff --git a/packages/connector/src/session.ts b/packages/connector/src/session.ts index d4b3a76671..0ce670caf8 100644 --- a/packages/connector/src/session.ts +++ b/packages/connector/src/session.ts @@ -18,7 +18,12 @@ export default class SessionConnector extends InjectedConnector { } async disconnect() { - this.controller.disconnect(); + await this.controller.disconnect(); + try { + await super.disconnect(); + } catch { + // Best-effort: disconnect should not throw if the injected wallet isn't available. + } } static fromConnectors(connectors: Connector[]): SessionConnector { diff --git a/packages/connector/vitest.config.ts b/packages/connector/vitest.config.ts new file mode 100644 index 0000000000..e8c4d09094 --- /dev/null +++ b/packages/connector/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); + diff --git a/packages/controller/HEADLESS_MODE.md b/packages/controller/HEADLESS_MODE.md new file mode 100644 index 0000000000..38657e67a6 --- /dev/null +++ b/packages/controller/HEADLESS_MODE.md @@ -0,0 +1,113 @@ +# Headless Mode Guide + +## Overview + +Headless mode enables programmatic authentication with the Cartridge Controller SDK without displaying any UI. You trigger headless mode by passing a `username` and `signer` to `connect(...)`. + +``` +Controller SDK → Keychain iframe (hidden) → Backend API +``` + +**Key Points** +- The keychain iframe still exists, but the modal is not opened. +- The SDK passes the connect request to keychain over Penpal. +- Keychain executes the same authentication logic as the UI flow. +- No duplicated auth logic in the SDK. + +## Usage + +### Basic (Passkey / WebAuthn) + +```ts +import Controller from "@cartridge/controller"; + +const controller = new Controller({ + defaultChainId: "SN_SEPOLIA", +}); + +await controller.connect({ + username: "alice", + signer: "webauthn", +}); +``` + +### Password + +```ts +await controller.connect({ + username: "alice", + signer: "password", + password: "correct horse battery staple", +}); +``` + +### OAuth / EVM / WalletConnect + +```ts +// Google / Discord +await controller.connect({ username: "alice", signer: "google" }); +await controller.connect({ username: "alice", signer: "discord" }); + +// EVM wallets +await controller.connect({ username: "alice", signer: "metamask" }); +await controller.connect({ username: "alice", signer: "rabby" }); +await controller.connect({ username: "alice", signer: "phantom-evm" }); + +// WalletConnect +await controller.connect({ username: "alice", signer: "walletconnect" }); +``` + +## Supported Auth Options + +Headless mode supports all **implemented** auth options: +- `webauthn` +- `password` +- `google` +- `discord` +- `walletconnect` +- `metamask` +- `rabby` +- `phantom-evm` + +## Handling Session Approval + +If policies are unverified or include approvals, Keychain will prompt for +session approval **after** authentication. In that case, `connect` will open +the approval UI and resolve once the session is approved. + +```ts +const account = await controller.connect({ + username: "alice", + signer: "webauthn", +}); + +console.log("Session approved:", account.address); +``` +If you want to react to connection state changes, subscribe to the standard +wallet events (for example `accountsChanged`) or just await `connect(...)` and +update your app state afterwards. + +## Error Handling + +The SDK provides specific error classes for headless mode: + +```ts +import { + HeadlessAuthenticationError, +} from "@cartridge/controller"; + +try { + await controller.connect({ username: "alice", signer: "webauthn" }); +} catch (error) { + if (error instanceof HeadlessAuthenticationError) { + // Auth failed (invalid credentials, signer mismatch, etc.) + } +} +``` + +## Notes + +- Headless mode uses the **existing signers** on the controller for the given username. +- For passkeys, the account must already have a WebAuthn signer registered. +- If policies are unverified or include approvals, Keychain will request + explicit approval after authentication. diff --git a/packages/controller/src/__tests__/headlessConnectApproval.test.ts b/packages/controller/src/__tests__/headlessConnectApproval.test.ts new file mode 100644 index 0000000000..05615a9664 --- /dev/null +++ b/packages/controller/src/__tests__/headlessConnectApproval.test.ts @@ -0,0 +1,97 @@ +import { ResponseCodes } from "../types"; + +let iframeOpen: jest.Mock | undefined; + +// Mock the KeychainIFrame so we can manually trigger onSessionCreated. +jest.mock("../iframe/keychain", () => ({ + KeychainIFrame: jest.fn().mockImplementation((_opts: any) => { + iframeOpen = jest.fn(); + return { + open: iframeOpen, + close: jest.fn(), + }; + }), +})); + +// Keep ControllerAccount lightweight for this test. +jest.mock("../account", () => ({ + __esModule: true, + default: jest + .fn() + .mockImplementation( + ( + _provider: unknown, + _rpcUrl: string, + address: string, + _keychain: unknown, + _options: unknown, + ) => ({ + address, + }), + ), +})); + +import ControllerProvider from "../controller"; + +describe("headless connect requiring approval", () => { + test("does not open UI and resolves connect() only after keychain.connect()", async () => { + const controller = new ControllerProvider(); + + let resolveConnect: ((value: any) => void) | undefined; + const connectPromise = new Promise((resolve) => { + resolveConnect = resolve; + }); + + const keychain = { + connect: jest.fn().mockReturnValue(connectPromise), + disconnect: jest.fn(), + reset: jest.fn(), + } as any; + + // Avoid waiting for Penpal connection in this unit test. + (controller as any).keychain = keychain; + (controller as any).waitForKeychain = () => Promise.resolve(); + + const accountsChanged = jest.fn(); + controller.on("accountsChanged", accountsChanged as any); + + const controllerConnectPromise = controller.connect({ + username: "alice", + signer: "webauthn", + }); + + let resolved = false; + void controllerConnectPromise.then(() => { + resolved = true; + }); + + // Flush microtasks for: + // 1) await waitForKeychain() + // 2) await keychain.connect(...) + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(keychain.connect).toHaveBeenCalledWith({ + username: "alice", + signer: "webauthn", + password: undefined, + }); + expect(iframeOpen).not.toHaveBeenCalled(); + expect(resolved).toBe(false); + + resolveConnect?.({ + code: ResponseCodes.SUCCESS, + address: "0xabc", + }); + + const account = await controllerConnectPromise; + expect(account?.address).toBe("0xabc"); + expect(accountsChanged).toHaveBeenCalledWith(["0xabc"]); + + // Subsequent connect() should short-circuit (no second approval flow). + const account2 = await controller.connect(); + expect(account2?.address).toBe("0xabc"); + expect(keychain.connect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/controller/src/__tests__/parseChainId.test.ts b/packages/controller/src/__tests__/parseChainId.test.ts index dc073fe902..00526b42ee 100644 --- a/packages/controller/src/__tests__/parseChainId.test.ts +++ b/packages/controller/src/__tests__/parseChainId.test.ts @@ -44,7 +44,7 @@ describe("parseChainId", () => { describe("Non-Cartridge hosts", () => { test("returns placeholder chainId in Node", () => { - expect(parseChainId(new URL("http://dl:123123"))).toBe( + expect(parseChainId(new URL("http://dl:1234"))).toBe( shortString.encodeShortString("LOCALHOST"), ); }); diff --git a/packages/controller/src/controller.ts b/packages/controller/src/controller.ts index 06c89240b1..d997d8bde2 100644 --- a/packages/controller/src/controller.ts +++ b/packages/controller/src/controller.ts @@ -12,7 +12,7 @@ import { constants, shortString, WalletAccount } from "starknet"; import { version } from "../package.json"; import ControllerAccount from "./account"; import { KEYCHAIN_URL } from "./constants"; -import { NotReadyToConnect } from "./errors"; +import { HeadlessAuthenticationError, NotReadyToConnect } from "./errors"; import { KeychainIFrame } from "./iframe"; import BaseProvider from "./provider"; import { @@ -20,6 +20,7 @@ import { Chain, ConnectError, ConnectReply, + ConnectOptions, ControllerOptions, IFrames, Keychain, @@ -228,8 +229,18 @@ export default class ControllerProvider extends BaseProvider { } async connect( - signupOptions?: AuthOptions, + options?: AuthOptions | ConnectOptions, ): Promise { + const connectOptions = Array.isArray(options) ? undefined : options; + const headless = + connectOptions?.username && connectOptions?.signer + ? { + username: connectOptions.username, + signer: connectOptions.signer, + password: connectOptions.password, + } + : undefined; + if (!this.iframes) { return; } @@ -241,21 +252,73 @@ export default class ControllerProvider extends BaseProvider { // Ensure iframe is created if using lazy loading if (!this.iframes.keychain) { this.iframes.keychain = this.createKeychainIframe(); - // Wait for the keychain to be ready - await this.waitForKeychain(); } + // Always wait for the keychain connection to be established + await this.waitForKeychain(); + if (!this.keychain || !this.iframes.keychain) { console.error(new NotReadyToConnect().message); return; } - this.iframes.keychain.open(); - try { + if (headless) { + // Headless auth should not open the UI until the keychain determines + // user interaction is required (e.g. session approval). + const response = await this.keychain.connect({ + username: headless.username, + signer: headless.signer, + password: headless.password, + }); + + if (response.code !== ResponseCodes.SUCCESS) { + throw new HeadlessAuthenticationError( + "message" in response && response.message + ? response.message + : "Headless authentication failed", + ); + } + + // Keychain will call onSessionCreated (awaitable) during headless connect, + // which probes and updates this.account. Keep a fallback for older keychains. + if (this.account) { + return this.account; + } + + const address = + "address" in response && response.address ? response.address : null; + if (!address) { + throw new HeadlessAuthenticationError( + "Headless authentication failed", + ); + } + + this.account = new ControllerAccount( + this, + this.rpcUrl(), + address, + this.keychain, + this.options, + this.iframes.keychain, + ); + this.emitAccountsChanged([address]); + return this.account; + } + + // Only open modal if NOT headless + this.iframes.keychain.open(); + // 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 = Array.isArray(options) + ? options + : (connectOptions?.signupOptions ?? this.options.signupOptions); + + // Pass options to keychain + let response = await this.keychain.connect({ + signupOptions: effectiveOptions, + }); + if (response.code !== ResponseCodes.SUCCESS) { throw new Error(response.message); } @@ -272,9 +335,25 @@ export default class ControllerProvider extends BaseProvider { return this.account; } catch (e) { + if (headless) { + if (e instanceof HeadlessAuthenticationError) { + throw e; + } + + const message = + e instanceof Error + ? e.message + : typeof e === "object" && e && "message" in e + ? String((e as any).message) + : "Headless authentication failed"; + throw new HeadlessAuthenticationError(message); + } console.log(e); } finally { - this.iframes.keychain.close(); + // Only close modal if it was opened (not headless) + if (!headless) { + this.iframes.keychain.close(); + } } } @@ -308,12 +387,22 @@ export default class ControllerProvider extends BaseProvider { } async disconnect() { + this.account = undefined; + this.emitAccountsChanged([]); + + try { + if (typeof localStorage !== "undefined") { + localStorage.removeItem("lastUsedConnector"); + } + } catch { + // Ignore environments where localStorage is unavailable. + } + if (!this.keychain) { console.error(new NotReadyToConnect().message); return; } - this.account = undefined; return this.keychain.disconnect(); } @@ -679,7 +768,9 @@ export default class ControllerProvider extends BaseProvider { const iframe = new KeychainIFrame({ ...this.options, rpcUrl: this.rpcUrl(), - onClose: this.keychain?.reset, + onClose: () => { + this.keychain?.reset?.(); + }, onConnect: (keychain) => { this.keychain = keychain; }, @@ -690,8 +781,12 @@ export default class ControllerProvider extends BaseProvider { encryptedBlob: encryptedBlob ?? undefined, username: username, onSessionCreated: async () => { - // Re-probe to establish connection now that storage access is granted and session created - await this.probe(); + const previousAddress = this.account?.address; + const account = await this.probe(); + + if (account?.address && account.address !== previousAddress) { + this.emitAccountsChanged([account.address]); + } }, }); diff --git a/packages/controller/src/errors.ts b/packages/controller/src/errors.ts index d1011727d2..f2a71e8c60 100644 --- a/packages/controller/src/errors.ts +++ b/packages/controller/src/errors.ts @@ -5,3 +5,33 @@ export class NotReadyToConnect extends Error { Object.setPrototypeOf(this, NotReadyToConnect.prototype); } } + +export class HeadlessAuthenticationError extends Error { + constructor( + message: string, + public cause?: Error, + ) { + super(message); + this.name = "HeadlessAuthenticationError"; + + Object.setPrototypeOf(this, HeadlessAuthenticationError.prototype); + } +} + +export class InvalidCredentialsError extends HeadlessAuthenticationError { + constructor(credentialType: string) { + super(`Invalid credentials provided for type: ${credentialType}`); + this.name = "InvalidCredentialsError"; + + Object.setPrototypeOf(this, InvalidCredentialsError.prototype); + } +} + +export class HeadlessModeNotSupportedError extends Error { + constructor(operation: string) { + super(`Operation "${operation}" is not supported in headless mode`); + this.name = "HeadlessModeNotSupportedError"; + + Object.setPrototypeOf(this, HeadlessModeNotSupportedError.prototype); + } +} diff --git a/packages/controller/src/iframe/base.ts b/packages/controller/src/iframe/base.ts index ee49d86025..9236d08a05 100644 --- a/packages/controller/src/iframe/base.ts +++ b/packages/controller/src/iframe/base.ts @@ -126,6 +126,7 @@ export class IFrame implements Modal { iframe: this.iframe, childOrigin: url.origin, methods: { + open: (_origin: string) => () => this.open(), close: (_origin: string) => () => this.close(), reload: (_origin: string) => () => window.location.reload(), ...methods, diff --git a/packages/controller/src/iframe/keychain.ts b/packages/controller/src/iframe/keychain.ts index 753e1a35cf..7dd1ca3372 100644 --- a/packages/controller/src/iframe/keychain.ts +++ b/packages/controller/src/iframe/keychain.ts @@ -119,11 +119,7 @@ export class KeychainIFrame extends IFrame { methods: { ...walletBridge.getIFrameMethods(), // Expose callback for keychain to notify parent that session was created and storage access granted - onSessionCreated: (_origin: string) => () => { - if (onSessionCreated) { - onSessionCreated(); - } - }, + onSessionCreated: (_origin: string) => () => onSessionCreated?.(), onStarterpackPlay: (_origin: string) => async () => { if (onStarterpackPlayHandler) { await onStarterpackPlayHandler(); diff --git a/packages/controller/src/session/provider.ts b/packages/controller/src/session/provider.ts index 894e37264e..1a96f28462 100644 --- a/packages/controller/src/session/provider.ts +++ b/packages/controller/src/session/provider.ts @@ -315,6 +315,7 @@ export default class SessionProvider extends BaseProvider { localStorage.removeItem("sessionPolicies"); localStorage.removeItem("lastUsedConnector"); this.account = undefined; + this.emitAccountsChanged([]); this._username = undefined; const disconnectUrl = new URL(`${this._keychainUrl}`); disconnectUrl.pathname = "disconnect"; diff --git a/packages/controller/src/types.ts b/packages/controller/src/types.ts index 6064d08a03..aa64fd4cfe 100644 --- a/packages/controller/src/types.ts +++ b/packages/controller/src/types.ts @@ -131,7 +131,7 @@ export type ControllerAccounts = Record; export interface Keychain { probe(rpcUrl: string): Promise; - connect(signupOptions?: AuthOptions): Promise; + connect(options?: ConnectOptions): Promise; disconnect(): void; reset(): void; @@ -276,3 +276,32 @@ export type StarterpackOptions = { /** Callback fired after the Play button closes the starterpack modal */ onPurchaseComplete?: () => void; }; + +// Connect options (used by controller.connect) +export interface ConnectOptions { + /** Signup options (shown in UI when not headless) */ + signupOptions?: AuthOptions; + /** Headless mode username (when combined with signer) */ + username?: string; + /** Headless mode signer option (auth method) */ + signer?: AuthOption; + /** Required when signer is "password" */ + password?: string; +} + +export type HeadlessConnectOptions = Required< + Pick +> & + Pick; + +export type HeadlessConnectReply = + | { + code: ResponseCodes.SUCCESS; + address: string; + } + | { + code: ResponseCodes.USER_INTERACTION_REQUIRED; + requestId: string; + message?: string; + } + | ConnectError; diff --git a/packages/keychain/.prettierignore b/packages/keychain/.prettierignore index c31957c6a5..873e5d5cd3 100644 --- a/packages/keychain/.prettierignore +++ b/packages/keychain/.prettierignore @@ -1 +1,2 @@ -public/** \ No newline at end of file +public/** +coverage/** diff --git a/packages/keychain/src/components/ConnectRoute.test.tsx b/packages/keychain/src/components/ConnectRoute.test.tsx index 9311e36ded..edf2da237c 100644 --- a/packages/keychain/src/components/ConnectRoute.test.tsx +++ b/packages/keychain/src/components/ConnectRoute.test.tsx @@ -78,7 +78,6 @@ describe("ConnectRoute", () => { reject: vi.fn(), params: { id: "test-id" }, }; - beforeEach(() => { vi.clearAllMocks(); mockIsIframe.mockReturnValue(true); // Default to embedded mode @@ -90,6 +89,7 @@ describe("ConnectRoute", () => { mockUseConnection.mockReturnValue({ controller: mockController, policies: null, + isPoliciesResolved: true, verified: true, origin: "https://test.app", theme: { @@ -108,6 +108,7 @@ describe("ConnectRoute", () => { mockUseConnection.mockReturnValue({ controller: mockController, policies: null, + isPoliciesResolved: true, verified: true, origin: "https://test.app", }); @@ -131,6 +132,7 @@ describe("ConnectRoute", () => { contracts: {}, messages: [], }, + isPoliciesResolved: true, verified: true, origin: "https://test.app", }); @@ -158,6 +160,7 @@ describe("ConnectRoute", () => { }, messages: [], }, + isPoliciesResolved: true, verified: false, origin: "https://test.app", }); @@ -176,6 +179,7 @@ describe("ConnectRoute", () => { contracts: {}, messages: [], }, + isPoliciesResolved: true, verified: true, origin: "https://test.app", }); @@ -206,6 +210,7 @@ describe("ConnectRoute", () => { }, messages: [], }, + isPoliciesResolved: true, verified: true, origin: "https://test.app", theme: { @@ -234,6 +239,7 @@ describe("ConnectRoute", () => { mockUseConnection.mockReturnValue({ controller: mockController, policies: null, + isPoliciesResolved: true, verified: true, origin: "https://test.app", theme: { @@ -267,6 +273,7 @@ describe("ConnectRoute", () => { contracts: {}, messages: [], }, + isPoliciesResolved: true, verified: true, origin: "https://test.app", theme: { @@ -298,6 +305,7 @@ describe("ConnectRoute", () => { }, messages: [], }, + isPoliciesResolved: true, verified: false, origin: "https://test.app", }); @@ -319,6 +327,7 @@ describe("ConnectRoute", () => { mockUseConnection.mockReturnValue({ controller: mockController, policies: null, + isPoliciesResolved: true, verified: false, origin: "https://test.app", theme: { @@ -346,6 +355,7 @@ describe("ConnectRoute", () => { mockUseConnection.mockReturnValue({ controller: mockController, policies: null, + isPoliciesResolved: true, verified: true, origin: "https://test.app", }); @@ -374,6 +384,7 @@ describe("ConnectRoute", () => { contracts: {}, messages: [], }, + isPoliciesResolved: true, verified: true, origin: "https://test.app", }); @@ -389,6 +400,7 @@ describe("ConnectRoute", () => { mockUseConnection.mockReturnValue({ controller: null, policies: null, + isPoliciesResolved: true, verified: false, origin: "https://test.app", }); @@ -411,6 +423,7 @@ describe("ConnectRoute", () => { mockUseConnection.mockReturnValue({ controller: mockController, policies: null, + isPoliciesResolved: true, verified: true, origin: "https://test.app", theme: { @@ -442,6 +455,7 @@ describe("ConnectRoute", () => { mockUseConnection.mockReturnValue({ controller: mockController, policies: null, + isPoliciesResolved: true, verified: true, origin: "https://test.app", }); @@ -469,6 +483,7 @@ describe("ConnectRoute", () => { }, messages: [{ id: "3", content: "Sign this", authorized: true }], }, + isPoliciesResolved: true, verified: true, origin: "https://test.app", }); diff --git a/packages/keychain/src/components/ConnectRoute.tsx b/packages/keychain/src/components/ConnectRoute.tsx index c0f83b12f8..74bc7a8d57 100644 --- a/packages/keychain/src/components/ConnectRoute.tsx +++ b/packages/keychain/src/components/ConnectRoute.tsx @@ -4,8 +4,11 @@ import { useConnection } from "@/hooks/connection"; import { hasApprovalPolicies } from "@/hooks/session"; import { cleanupCallbacks } from "@/utils/connection/callbacks"; import { parseConnectParams } from "@/utils/connection/connect"; -import { CreateSession, processPolicies } from "./connect/CreateSession"; -import { now } from "@/constants"; +import { CreateSession } from "./connect/CreateSession"; +import { + createVerifiedSession, + requiresSessionApproval, +} from "@/utils/connection/session-creation"; import { useRouteParams, useRouteCompletion, @@ -21,7 +24,7 @@ const CANCEL_RESPONSE = { }; export function ConnectRoute() { - const { controller, policies, origin } = useConnection(); + const { controller, policies, origin, isPoliciesResolved } = useConnection(); const [hasAutoConnected, setHasAutoConnected] = useState(false); // Parse params and set RPC URL immediately @@ -50,6 +53,12 @@ export function ConnectRoute() { [policies], ); + const clearConnectParams = useCallback(() => { + const url = new URL(window.location.href); + url.search = ""; + window.history.replaceState(null, "", url.toString()); + }, []); + const handleConnect = useCallback(async () => { if (!params || !controller) { return; @@ -72,6 +81,7 @@ export function ConnectRoute() { if (params.params.id) { cleanupCallbacks(params.params.id); } + clearConnectParams(); // In standalone mode with redirect_url, redirect instead of calling handleCompletion // Add lastUsedConnector query param to indicate controller was used @@ -104,7 +114,14 @@ export function ConnectRoute() { } handleCompletion(); - }, [params, controller, handleCompletion, isStandalone, redirectUrl]); + }, [ + params, + controller, + clearConnectParams, + handleCompletion, + isStandalone, + redirectUrl, + ]); const handleSkip = useCallback(async () => { if (!params || !controller) { @@ -126,6 +143,7 @@ export function ConnectRoute() { if (params.params.id) { cleanupCallbacks(params.params.id); } + clearConnectParams(); // In standalone mode with redirect_url, redirect instead of calling handleCompletion // Add lastUsedConnector query param to indicate controller was used @@ -158,7 +176,14 @@ export function ConnectRoute() { } handleCompletion(); - }, [params, controller, handleCompletion, isStandalone, redirectUrl]); + }, [ + params, + controller, + clearConnectParams, + handleCompletion, + isStandalone, + redirectUrl, + ]); // Handle cases where we can connect immediately (embedded mode only) useEffect(() => { @@ -166,6 +191,10 @@ export function ConnectRoute() { return; } + if (!isPoliciesResolved) { + return; + } + // In standalone mode with redirect_url, redirect immediately // if (isStandalone && redirectUrl) { // console.log("redirecting effect"); @@ -211,19 +240,14 @@ export function ConnectRoute() { // Bypass session approval screen for verified sessions in embedded mode // Note: This is a fallback - main logic is handled in useCreateController - if (policies.verified) { - if (hasTokenApprovals) { - return; - } - + if (!requiresSessionApproval(policies)) { const createSessionForVerifiedPolicies = async () => { try { - // Use a default duration for verified sessions (24 hours) - const duration = BigInt(24 * 60 * 60); // 24 hours in seconds - const expiresAt = duration + now(); - - const processedPolicies = processPolicies(policies, false); - await controller.createSession(origin, expiresAt, processedPolicies); + await createVerifiedSession({ + controller, + origin, + policies, + }); params.resolve?.({ code: ResponseCodes.SUCCESS, address: controller.address(), @@ -250,6 +274,7 @@ export function ConnectRoute() { redirectUrl, hasAutoConnected, hasTokenApprovals, + isPoliciesResolved, origin, ]); diff --git a/packages/keychain/src/components/app.tsx b/packages/keychain/src/components/app.tsx index 2674474e1c..a085fc717c 100644 --- a/packages/keychain/src/components/app.tsx +++ b/packages/keychain/src/components/app.tsx @@ -51,6 +51,7 @@ import { Collections } from "./purchasenew/starterpack/collections"; import { DeployController } from "./DeployController"; import { useConnection } from "@/hooks/connection"; import { CreateController, Upgrade } from "./connect"; +import { HeadlessApprovalRoute } from "./connect/HeadlessApprovalRoute"; import { useUpgrade } from "./provider/upgrade"; import { Layout } from "@/components/layout"; import { Authenticate } from "./authenticate"; @@ -155,7 +156,18 @@ function Authentication() { if (signersParam) { try { - signers = JSON.parse(decodeURIComponent(signersParam)) as AuthOptions; + const parsed = JSON.parse(decodeURIComponent(signersParam)) as + | AuthOptions + | { signupOptions?: AuthOptions }; + if (Array.isArray(parsed)) { + signers = parsed as AuthOptions; + } else if ( + parsed && + typeof parsed === "object" && + Array.isArray(parsed.signupOptions) + ) { + signers = parsed.signupOptions; + } } catch (error) { console.error("Failed to parse signers parameter:", error); // Continue with undefined signers on parse error @@ -292,6 +304,10 @@ export function App() { } /> } /> } /> + } + /> } /> }> } /> diff --git a/packages/keychain/src/components/connect/CreateSession.tsx b/packages/keychain/src/components/connect/CreateSession.tsx index 54b4c253fa..f121cf8edf 100644 --- a/packages/keychain/src/components/connect/CreateSession.tsx +++ b/packages/keychain/src/components/connect/CreateSession.tsx @@ -10,6 +10,7 @@ import { useCreateSession, hasApprovalPolicies, } from "@/hooks/session"; +import { processPolicies } from "@/utils/session/policies"; import type { ControllerError } from "@/utils/connection"; import { Button, @@ -305,42 +306,5 @@ const CreateSessionLayout = ({ ); }; -/** - * Deep copy the policies and remove the id fields - * @param policies The policies to clean - * @param toggleOff Optional. When true, sets all policies to unauthorized (false) - */ -export const processPolicies = ( - policies: ParsedSessionPolicies, - toggleOff?: boolean, -): ParsedSessionPolicies => { - // Deep copy the policies - const processPolicies: ParsedSessionPolicies = JSON.parse( - JSON.stringify(policies), - ); - - // Remove the id fields from the methods and optionally set authorized to false - if (processPolicies.contracts) { - Object.values(processPolicies.contracts).forEach((contract) => { - contract.methods.forEach((method) => { - delete method.id; - if (toggleOff !== undefined) { - method.authorized = !toggleOff; - } - }); - }); - } - - // Remove the id fields from the messages and optionally set authorized to false - if (processPolicies.messages) { - processPolicies.messages.forEach((message) => { - delete message.id; - if (toggleOff !== undefined) { - message.authorized = !toggleOff; - } - }); - } - - // Return the cleaned policies - return processPolicies; -}; +// Backwards compat: other modules import `processPolicies` from this component. +export { processPolicies }; diff --git a/packages/keychain/src/components/connect/HeadlessApprovalRoute.tsx b/packages/keychain/src/components/connect/HeadlessApprovalRoute.tsx new file mode 100644 index 0000000000..aa037c3b27 --- /dev/null +++ b/packages/keychain/src/components/connect/HeadlessApprovalRoute.tsx @@ -0,0 +1,96 @@ +import { HeaderInner } from "@cartridge/ui"; +import { useCallback, useEffect, useMemo } from "react"; +import { useParams } from "react-router-dom"; +import { useNavigation } from "@/context"; +import { useConnection } from "@/hooks/connection"; +import { + completeHeadlessApprovalRequest, + getHeadlessApprovalRequest, + rejectHeadlessApprovalRequest, + resolveHeadlessApprovalRequest, +} from "@/utils/connection/headless-requests"; +import { CreateSession } from "./CreateSession"; + +export function HeadlessApprovalRoute() { + const { requestId } = useParams<{ requestId: string }>(); + const { controller, policies, closeModal, setOnModalClose } = useConnection(); + const { navigate } = useNavigation(); + + const request = useMemo(() => { + if (!requestId) { + return undefined; + } + return getHeadlessApprovalRequest(requestId); + }, [requestId]); + + const handleComplete = useCallback(async () => { + if (!requestId || !controller) { + return; + } + + completeHeadlessApprovalRequest(requestId); + resolveHeadlessApprovalRequest(requestId); + closeModal?.(); + navigate("/", { replace: true }); + }, [requestId, controller, closeModal, navigate]); + + useEffect(() => { + if (!setOnModalClose || !requestId) return; + + setOnModalClose(() => { + // If the request still exists, the modal close is treated as a cancel. + if (!getHeadlessApprovalRequest(requestId)) { + return; + } + rejectHeadlessApprovalRequest( + requestId, + new Error("Headless session approval was canceled"), + ); + completeHeadlessApprovalRequest(requestId); + }); + + return () => { + setOnModalClose(() => {}); + }; + }, [setOnModalClose, requestId]); + + useEffect(() => { + return () => { + if (!requestId) return; + // If the request still exists, the user navigated away or canceled. + if (getHeadlessApprovalRequest(requestId)) { + rejectHeadlessApprovalRequest( + requestId, + new Error("Headless session approval was canceled"), + ); + completeHeadlessApprovalRequest(requestId); + } + }; + }, [requestId]); + + if (!requestId || !request) { + return ( + + ); + } + + if (!controller || !policies) { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/keychain/src/components/connect/StandaloneSessionCreation.tsx b/packages/keychain/src/components/connect/StandaloneSessionCreation.tsx index dc10ff061d..3bb5753055 100644 --- a/packages/keychain/src/components/connect/StandaloneSessionCreation.tsx +++ b/packages/keychain/src/components/connect/StandaloneSessionCreation.tsx @@ -22,7 +22,7 @@ import { import { useCallback, useMemo, useState, useRef, useEffect } from "react"; import { useSearchParams } from "react-router-dom"; import { SpendingLimitPage } from "./SpendingLimitPage"; -import { processPolicies } from "./CreateSession"; +import { processPolicies } from "@/utils/session/policies"; import Controller from "@/utils/controller"; /** diff --git a/packages/keychain/src/components/connect/create/CreateController.test.tsx b/packages/keychain/src/components/connect/create/CreateController.test.tsx index ec80bfa25a..9d81b3df38 100644 --- a/packages/keychain/src/components/connect/create/CreateController.test.tsx +++ b/packages/keychain/src/components/connect/create/CreateController.test.tsx @@ -99,6 +99,8 @@ describe("CreateController", () => { signupOptions: ["webauthn"], authMethod: undefined, setAuthMethod: vi.fn(), + shouldAutoCreateSession: true, + hasPolicies: true, }); mockUseUsernameValidation.mockReturnValue({ status: "valid", @@ -154,6 +156,8 @@ describe("CreateController", () => { signupOptions: ["webauthn"], authMethod: undefined, setAuthMethod: vi.fn(), + shouldAutoCreateSession: true, + hasPolicies: true, }); renderComponent(); const input = screen.getByPlaceholderText("Username"); @@ -201,6 +205,8 @@ describe("CreateController", () => { signupOptions: ["webauthn"], authMethod: undefined, setAuthMethod: vi.fn(), + shouldAutoCreateSession: true, + hasPolicies: true, }); renderComponent(); const submitButton = screen.getByTestId("submit-button"); @@ -271,6 +277,8 @@ describe("CreateController", () => { signupOptions: ["webauthn"], authMethod: undefined, setAuthMethod: vi.fn(), + shouldAutoCreateSession: true, + hasPolicies: true, }); renderWithProviders(); const input = screen.getByPlaceholderText("Username"); @@ -320,6 +328,8 @@ describe("CreateController", () => { signupOptions: ["webauthn"], authMethod: undefined, setAuthMethod: vi.fn(), + shouldAutoCreateSession: true, + hasPolicies: true, }); renderComponent(); const input = screen.getByPlaceholderText("Username"); @@ -358,6 +368,8 @@ describe("CreateController", () => { signupOptions: ["webauthn"], authMethod: undefined, setAuthMethod: vi.fn(), + shouldAutoCreateSession: true, + hasPolicies: true, }); renderComponent(); const input = screen.getByPlaceholderText("Username"); @@ -415,6 +427,8 @@ describe("CreateController", () => { signupOptions: ["webauthn"], authMethod: undefined, setAuthMethod: vi.fn(), + shouldAutoCreateSession: true, + hasPolicies: true, }); renderComponent(); diff --git a/packages/keychain/src/components/connect/create/useCreateController.ts b/packages/keychain/src/components/connect/create/useCreateController.ts index d74812eb96..3b58bd8433 100644 --- a/packages/keychain/src/components/connect/create/useCreateController.ts +++ b/packages/keychain/src/components/connect/create/useCreateController.ts @@ -22,6 +22,7 @@ import { useAccountQuery, WebauthnCredentials, } from "@cartridge/ui/utils/api/cartridge"; +import { getAddress } from "ethers"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { constants, shortString } from "starknet"; @@ -36,12 +37,15 @@ import { useSocialAuthentication } from "./social"; import { AuthenticationStep, fetchController } from "./utils"; import { useWalletConnectAuthentication } from "./wallet-connect"; import { useWebauthnAuthentication } from "./webauthn"; -import { processPolicies } from "../CreateSession"; import { cleanupCallbacks } from "@/utils/connection/callbacks"; import { useRouteCallbacks, useRouteCompletion } from "@/hooks/route"; import { parseConnectParams } from "@/utils/connection/connect"; -import { ParsedSessionPolicies, hasApprovalPolicies } from "@/hooks/session"; +import { ParsedSessionPolicies } from "@/hooks/session"; import { safeRedirect } from "@/utils/url-validator"; +import { + canAutoCreateSession, + createVerifiedSession, +} from "@/utils/connection/session-creation"; const CANCEL_RESPONSE = { code: ResponseCodes.CANCELED, @@ -118,12 +122,11 @@ const createSession = async ({ } try { - // Use a default duration for verified sessions (24 hours) - const duration = BigInt(24 * 60 * 60); // 24 hours in seconds - const expiresAt = duration + now(); - - const processedPolicies = processPolicies(policies, false); - await controller.createSession(origin, expiresAt, processedPolicies); + await createVerifiedSession({ + controller, + origin, + policies, + }); currentParams.resolve?.({ code: ResponseCodes.SUCCESS, address: controller.address(), @@ -160,14 +163,24 @@ export function useCreateController({ const [authenticationStep, setAuthenticationStep] = useState(AuthenticationStep.FillForm); const [searchParams, setSearchParams] = useSearchParams(); - const { origin, rpcUrl, chainId, setController, policies, closeModal } = - useConnection(); + const { + origin, + rpcUrl, + chainId, + setController, + policies, + closeModal, + isConfigLoading, + isPoliciesResolved, + } = useConnection(); // Import route params and completion for connection resolution const params = useMemo(() => { return parseConnectParams(searchParams); }, [searchParams]); const handleCompletion = useRouteCompletion(); + const hasPolicies = !!policies; + const shouldAutoCreateSession = canAutoCreateSession(policies); const { signup: signupWithWebauthn, login: loginWithWebauthn } = useWebauthnAuthentication(); @@ -342,9 +355,6 @@ export function useCreateController({ // } // Normal embedded flow: handle session creation for auto-close cases - const shouldAutoCreateSession = - !policies || (policies.verified && !hasApprovalPolicies(policies)); - if (shouldAutoCreateSession) { await createSession({ controller, @@ -370,6 +380,7 @@ export function useCreateController({ params, closeModal, searchParams, + shouldAutoCreateSession, ], ); @@ -496,7 +507,18 @@ export function useCreateController({ }) => { // Verify correct EVM wallet account is selected if (authenticationMethod !== "password") { - const connectedAddress = signerToAddress(loginResponse.signer); + const normalizeAddress = (address?: string) => { + if (!address) return undefined; + try { + return getAddress(address); + } catch { + return address.toLowerCase(); + } + }; + + const connectedAddress = normalizeAddress( + signerToAddress(loginResponse.signer), + ); const possibleSigners = controller.signers?.filter( (signer) => credentialToAuth(signer.metadata as CredentialMetadata) === @@ -514,8 +536,9 @@ export function useCreateController({ if ( !possibleSigners.find( (signer) => - credentialToAddress(signer.metadata as CredentialMetadata) === - connectedAddress, + normalizeAddress( + credentialToAddress(signer.metadata as CredentialMetadata), + ) === connectedAddress, ) ) { setChangeWallet(true); @@ -562,9 +585,6 @@ export function useCreateController({ // } // Normal embedded flow: handle session creation for auto-close cases - const shouldAutoCreateSession = - !policies || (policies.verified && !hasApprovalPolicies(policies)); - if (shouldAutoCreateSession) { await createSession({ controller: loginRet.controller, @@ -589,6 +609,7 @@ export function useCreateController({ params, closeModal, searchParams, + shouldAutoCreateSession, ], ); @@ -616,7 +637,7 @@ export function useCreateController({ if (!webauthnSigners || webauthnSigners.length === 0) { throw new Error("Signer not found for controller"); } - await loginWithWebauthn( + const loginController = await loginWithWebauthn( controller, { signer: { @@ -632,6 +653,21 @@ export function useCreateController({ }, !!isSlot, ); + if (!loginController) { + throw new Error("Login failed"); + } + + if (shouldAutoCreateSession) { + await createSession({ + controller: loginController, + origin, + policies, + params, + handleCompletion, + closeModal, + searchParams, + }); + } // Handle redirect_url for webauthn const urlSearchParams = new URLSearchParams(window.location.search); @@ -722,7 +758,14 @@ export function useCreateController({ loginWithWalletConnect, loginWithExternalWallet, chainId, + origin, rpcUrl, + policies, + params, + handleCompletion, + closeModal, + searchParams, + shouldAutoCreateSession, finishLogin, passwordAuth, setWaitingForConfirmation, @@ -911,5 +954,9 @@ export function useCreateController({ signupOptions, authMethod, setAuthMethod, + shouldAutoCreateSession, + isConfigLoading, + isPoliciesResolved, + hasPolicies, }; } diff --git a/packages/keychain/src/components/connect/create/webauthn/index.ts b/packages/keychain/src/components/connect/create/webauthn/index.ts index 7eae1b39d2..18ff1ac9a9 100644 --- a/packages/keychain/src/components/connect/create/webauthn/index.ts +++ b/packages/keychain/src/components/connect/create/webauthn/index.ts @@ -100,6 +100,7 @@ export function useWebauthnAuthentication() { window.controller = controller; setController(controller); + return controller; }, [chainId, rpcUrl, origin, setController], ); diff --git a/packages/keychain/src/components/provider/connection.tsx b/packages/keychain/src/components/provider/connection.tsx index dbddb89598..3e71ad7831 100644 --- a/packages/keychain/src/components/provider/connection.tsx +++ b/packages/keychain/src/components/provider/connection.tsx @@ -26,6 +26,7 @@ export type ConnectionContextValue = { policies?: ParsedSessionPolicies; theme: VerifiableControllerTheme; isConfigLoading: boolean; + isPoliciesResolved: boolean; isMainnet: boolean; verified: boolean; chainId?: string; diff --git a/packages/keychain/src/components/session.test.tsx b/packages/keychain/src/components/session.test.tsx index c5d8274c3e..d48dc0ca72 100644 --- a/packages/keychain/src/components/session.test.tsx +++ b/packages/keychain/src/components/session.test.tsx @@ -78,6 +78,7 @@ describe("Session", () => { cover: "https://test.app/cover.png", }, isConfigLoading: false, + isPoliciesResolved: true, isMainnet: false, verified: true, chainId: "SN_MAIN", diff --git a/packages/keychain/src/hooks/connection.ts b/packages/keychain/src/hooks/connection.ts index 5f396e3a5b..74d817e7b9 100644 --- a/packages/keychain/src/hooks/connection.ts +++ b/packages/keychain/src/hooks/connection.ts @@ -6,6 +6,7 @@ import { } from "@/components/provider/connection"; import { useNavigation } from "@/context/navigation"; import { connectToController } from "@/utils/connection"; +import type { HeadlessConnectionState } from "@/utils/connection/headless"; import { TurnkeyWallet } from "@/wallets/social/turnkey"; import { WalletConnectWallet } from "@/wallets/wallet-connect"; import { @@ -71,7 +72,16 @@ const TOKEN_ADDRESSES: Record = { usdt: USDT_CONTRACT_ADDRESS, }; +type ParentCallbackMethods = { + // Session creation callback (for standalone auth flow) + onSessionCreated?: () => Promise; + + // Starterpack play callback (for purchase completion flow) + onStarterpackPlay?: () => Promise; +}; + export type ParentMethods = AsyncMethodReturns<{ + open: () => Promise; close: () => Promise; reload: () => Promise; @@ -108,13 +118,8 @@ export type ParentMethods = AsyncMethodReturns<{ txHash: string, timeoutMs?: number, ) => Promise; - - // Session creation callback (for standalone auth flow) - onSessionCreated?: () => Promise; - - // Starterpack play callback (for purchase completion flow) - onStarterpackPlay?: () => Promise; -}>; +}> & + ParentCallbackMethods; /** * Parses policies from a URL string. @@ -190,13 +195,23 @@ function getConfigChainPolicies( export function useConnectionValue() { const { navigate } = useNavigation(); const [parent, setParent] = useState(); + const parentRef = useRef(); const [origin, setOrigin] = useState(undefined); const [rpcUrl, setRpcUrl] = useState( import.meta.env.VITE_RPC_MAINNET, ); const [policies, setPolicies] = useState(); + const [isPoliciesResolved, setIsPoliciesResolved] = useState(false); const [verified, setVerified] = useState(false); - const [isConfigLoading, setIsConfigLoading] = useState(false); + const initialPreset = + typeof window !== "undefined" + ? window.location.pathname.startsWith("/slot") + ? "slot" + : new URLSearchParams(window.location.search).get("preset") + : null; + const [isConfigLoading, setIsConfigLoading] = useState( + () => !!initialPreset, + ); const [isMainnet, setIsMainnet] = useState(false); const [configData, setConfigData] = useState | null>( null, @@ -208,6 +223,14 @@ export function useConnectionValue() { const [controller, setController] = useState(window.controller); const [chainId, setChainId] = useState(); const [controllerVersion, setControllerVersion] = useState(); + const connectionStateRef = useRef({ + origin, + chainId, + rpcUrl, + policies, + isPoliciesResolved, + isConfigLoading, + }); const [onModalClose, setOnModalCloseInternal] = useState< (() => void) | undefined >(); @@ -333,6 +356,22 @@ export function useConnectionValue() { }; if (rpcUrl) { + const inferChainIdFromRpcUrl = (url: string) => { + const lower = url.toLowerCase(); + if (lower.includes("sepolia")) { + return constants.StarknetChainId.SN_SEPOLIA; + } + if (lower.includes("mainnet")) { + return constants.StarknetChainId.SN_MAIN; + } + return undefined; + }; + + const inferredChainId = inferChainIdFromRpcUrl(rpcUrl); + if (inferredChainId) { + setChainId(inferredChainId); + } + fetchChainId(); } }, [rpcUrl]); @@ -593,7 +632,12 @@ export function useConnectionValue() { const { policies, preset } = urlParams; // Always prioritize preset policies over URL policies - if (preset && !isConfigLoading) { + if (preset) { + if (isConfigLoading || !chainId) { + setIsPoliciesResolved(false); + return; + } + const configPolicies = getConfigChainPolicies( configData, chainId, @@ -602,8 +646,12 @@ export function useConnectionValue() { if (configPolicies) { setPolicies(configPolicies); + setIsPoliciesResolved(true); return; } + + setIsPoliciesResolved(true); + return; } // Fall back to URL policies if no preset or preset has no policies @@ -611,10 +659,27 @@ export function useConnectionValue() { if (urlPolicies) { setPolicies(urlPolicies); } + + setIsPoliciesResolved(true); }, [urlParams, chainId, verified, configData, isConfigLoading]); useThemeEffect({ theme, assetUrl: "" }); + useEffect(() => { + connectionStateRef.current = { + origin, + chainId, + rpcUrl, + policies, + isPoliciesResolved, + isConfigLoading, + }; + }, [origin, chainId, rpcUrl, policies, isPoliciesResolved, isConfigLoading]); + + useEffect(() => { + parentRef.current = parent; + }, [parent]); + useEffect(() => { if (isIframe()) { const connection = connectToController({ @@ -623,6 +688,8 @@ export function useConnectionValue() { navigate, propagateError: urlParams.propagateError, errorDisplayMode: urlParams.errorDisplayMode, + getParent: () => parentRef.current, + getConnectionState: () => connectionStateRef.current, }); connection.promise @@ -662,6 +729,7 @@ export function useConnectionValue() { setOrigin(appOrigin); setParent({ + open: async () => {}, close: async () => {}, reload: async () => {}, externalDetectWallets: iframeMethods.externalDetectWallets(appOrigin), @@ -717,7 +785,7 @@ export function useConnectionValue() { if (!parent) return; try { - await parent.close(); + await parent.open(); } catch (e) { console.error("Failed to open modal:", e); } @@ -809,6 +877,7 @@ export function useConnectionValue() { tokens: urlParams.tokens, propagateError: urlParams.propagateError, isConfigLoading, + isPoliciesResolved, isMainnet, verified, chainId, diff --git a/packages/keychain/src/hooks/route.ts b/packages/keychain/src/hooks/route.ts index 2f92ba07be..5f33f57007 100644 --- a/packages/keychain/src/hooks/route.ts +++ b/packages/keychain/src/hooks/route.ts @@ -8,17 +8,20 @@ import { cleanupCallbacks } from "@/utils/connection/callbacks"; * @param parseParams - Function to parse the URLSearchParams * @returns Parsed params with callbacks, or null if parsing failed */ -export function useRouteParams( - parseParams: (searchParams: URLSearchParams) => { - params: T; - resolve?: (result: unknown) => void; - reject?: (reason?: unknown) => void; - onCancel?: () => void; - } | null, -) { +type RouteParams = { + params: T; + resolve?: (result: unknown) => void; + reject?: (reason?: unknown) => void; + onCancel?: () => void; +}; + +export function useRouteParams< + T extends { id?: string }, + R extends RouteParams, +>(parseParams: (searchParams: URLSearchParams) => R | null) { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const [params, setParams] = useState>(null); + const [params, setParams] = useState(null); // Parse URL params on mount or when searchParams change // Note: parseParams is intentionally not in the dependency array to avoid infinite loops diff --git a/packages/keychain/src/test/mocks/connection.tsx b/packages/keychain/src/test/mocks/connection.tsx index 00753b924f..d5d91c476b 100644 --- a/packages/keychain/src/test/mocks/connection.tsx +++ b/packages/keychain/src/test/mocks/connection.tsx @@ -30,6 +30,7 @@ export const defaultMockConnection: ConnectionContextValue = { namespace: null, verified: false, isConfigLoading: false, + isPoliciesResolved: true, isMainnet: false, theme: { verified: true, diff --git a/packages/keychain/src/utils/connection/callbacks.ts b/packages/keychain/src/utils/connection/callbacks.ts index c09e9783a7..e49d2c3d19 100644 --- a/packages/keychain/src/utils/connection/callbacks.ts +++ b/packages/keychain/src/utils/connection/callbacks.ts @@ -6,22 +6,50 @@ interface Callbacks { [key: string]: unknown; } -const globalCallbacks = new Map(); +const CALLBACKS_KEY = "__cartridge_controller_callbacks"; +const CALLBACK_COUNTER_KEY = "__cartridge_controller_callback_counter"; + +const fallbackCallbacks = new Map(); + +const getGlobalCallbacks = (): Map => { + if (typeof window === "undefined") { + return fallbackCallbacks; + } + + const globalWindow = window as typeof window & { + [CALLBACKS_KEY]?: Map; + }; + + if (!globalWindow[CALLBACKS_KEY]) { + globalWindow[CALLBACKS_KEY] = fallbackCallbacks; + } + + return globalWindow[CALLBACKS_KEY]; +}; let callbackIdCounter = 0; export function storeCallbacks(id: string, callbacks: Callbacks) { - globalCallbacks.set(id, callbacks); + getGlobalCallbacks().set(id, callbacks); } export function getCallbacks(id: string) { - return globalCallbacks.get(id); + return getGlobalCallbacks().get(id); } export function cleanupCallbacks(id: string) { - globalCallbacks.delete(id); + getGlobalCallbacks().delete(id); } export function generateCallbackId(): string { + if (typeof window !== "undefined") { + const globalWindow = window as typeof window & { + [CALLBACK_COUNTER_KEY]?: number; + }; + const next = (globalWindow[CALLBACK_COUNTER_KEY] ?? 0) + 1; + globalWindow[CALLBACK_COUNTER_KEY] = next; + return `${next}`; + } + return `${++callbackIdCounter}`; } diff --git a/packages/keychain/src/utils/connection/connect-routing.test.ts b/packages/keychain/src/utils/connection/connect-routing.test.ts new file mode 100644 index 0000000000..d52c39815a --- /dev/null +++ b/packages/keychain/src/utils/connection/connect-routing.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { ResponseCodes } from "@cartridge/controller"; +import type { AuthOptions, ConnectOptions } from "@cartridge/controller"; +import { createConnectHandler } from "./connect-routing"; + +describe("connect routing", () => { + const navigate = vi.fn(); + const uiConnect = vi.fn(); + const headlessConnect = vi.fn(); + const waitForApproval = vi.fn(); + + const open = vi.fn(); + const onSessionCreated = vi.fn(); + const getParent = () => ({ open, onSessionCreated }); + + const getConnectedAddress = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + // Silence intentional error logs from safeCall. + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("delegates to uiConnect for non-headless options", async () => { + uiConnect.mockResolvedValue({ + code: ResponseCodes.SUCCESS, + address: "0x1", + }); + + const handler = createConnectHandler({ + uiConnect, + headlessConnect, + navigate, + getParent, + waitForApproval, + getConnectedAddress, + }); + + const result = await handler(["webauthn"] as unknown as AuthOptions); + expect(result).toEqual({ code: ResponseCodes.SUCCESS, address: "0x1" }); + expect(uiConnect).toHaveBeenCalledTimes(1); + expect(headlessConnect).not.toHaveBeenCalled(); + }); + + it("awaits onSessionCreated for headless immediate success", async () => { + headlessConnect.mockResolvedValue({ + code: ResponseCodes.SUCCESS, + address: "0xabc", + }); + + const handler = createConnectHandler({ + uiConnect, + headlessConnect, + navigate, + getParent, + waitForApproval, + getConnectedAddress, + }); + + const result = await handler({ + username: "alice", + signer: "webauthn", + } as unknown as ConnectOptions); + + expect(result).toEqual({ code: ResponseCodes.SUCCESS, address: "0xabc" }); + expect(onSessionCreated).toHaveBeenCalledTimes(1); + expect(open).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + expect(waitForApproval).not.toHaveBeenCalled(); + }); + + it("opens approval route and resolves only after approval", async () => { + headlessConnect.mockResolvedValue({ + code: ResponseCodes.USER_INTERACTION_REQUIRED, + requestId: "req-1", + }); + waitForApproval.mockResolvedValue(undefined); + getConnectedAddress.mockReturnValue("0xdead"); + + const handler = createConnectHandler({ + uiConnect, + headlessConnect, + navigate, + getParent, + waitForApproval, + getConnectedAddress, + }); + + const result = await handler({ + username: "alice", + signer: "webauthn", + } as unknown as ConnectOptions); + + expect(navigate).toHaveBeenCalledWith("/headless-approval/req-1", { + replace: true, + }); + expect(open).toHaveBeenCalledTimes(1); + expect(waitForApproval).toHaveBeenCalledWith("req-1"); + expect(onSessionCreated).toHaveBeenCalledTimes(1); + expect(result).toEqual({ code: ResponseCodes.SUCCESS, address: "0xdead" }); + }); + + it("does not fail connect when onSessionCreated throws", async () => { + headlessConnect.mockResolvedValue({ + code: ResponseCodes.SUCCESS, + address: "0xabc", + }); + onSessionCreated.mockRejectedValueOnce(new Error("boom")); + + const handler = createConnectHandler({ + uiConnect, + headlessConnect, + navigate, + getParent, + waitForApproval, + getConnectedAddress, + }); + + const result = await handler({ + username: "alice", + signer: "webauthn", + } as unknown as ConnectOptions); + + expect(result).toEqual({ code: ResponseCodes.SUCCESS, address: "0xabc" }); + }); +}); diff --git a/packages/keychain/src/utils/connection/connect-routing.ts b/packages/keychain/src/utils/connection/connect-routing.ts new file mode 100644 index 0000000000..64c0d89791 --- /dev/null +++ b/packages/keychain/src/utils/connection/connect-routing.ts @@ -0,0 +1,126 @@ +import { ResponseCodes } from "@cartridge/controller"; +import type { + AuthOptions, + ConnectOptions, + SessionPolicies, + HeadlessConnectOptions, + HeadlessConnectReply, +} from "@cartridge/controller"; + +type NavigateFn = ( + to: string | number, + options?: { replace?: boolean; state?: unknown }, +) => void; + +type UiConnectFn = ( + policiesOrOptions?: SessionPolicies | AuthOptions | ConnectOptions, + rpcUrl?: string, + signupOptions?: AuthOptions, +) => Promise; + +type HeadlessConnectFn = ( + options: HeadlessConnectOptions, +) => Promise; + +type ParentLike = { + open?: () => void | Promise; + onSessionCreated?: () => void | Promise; +}; + +export function isHeadlessConnectOptions( + value: unknown, +): value is HeadlessConnectOptions { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const obj = value as Record; + return ( + typeof obj.username === "string" && + obj.username.length > 0 && + typeof obj.signer === "string" && + obj.signer.length > 0 + ); +} + +const safeCall = async (fn?: () => void | Promise, label?: string) => { + if (!fn) return; + try { + await fn(); + } catch (error) { + console.error(`[connect] ${label ?? "callback"} failed:`, error); + } +}; + +export function createConnectHandler({ + uiConnect, + headlessConnect, + navigate, + getParent, + waitForApproval, + getConnectedAddress, +}: { + uiConnect: UiConnectFn; + headlessConnect: HeadlessConnectFn; + navigate: NavigateFn; + getParent: () => ParentLike | undefined; + waitForApproval: (requestId: string) => Promise; + getConnectedAddress: () => string | undefined; +}) { + return async ( + policiesOrOptions?: SessionPolicies | AuthOptions | ConnectOptions, + rpcUrl?: string, + signupOptions?: AuthOptions, + ) => { + if (!isHeadlessConnectOptions(policiesOrOptions)) { + return uiConnect(policiesOrOptions, rpcUrl, signupOptions); + } + + const { username, signer, password } = policiesOrOptions as ConnectOptions; + const response = await headlessConnect({ + username: username!, + signer: signer!, + password, + }); + + if (response.code === ResponseCodes.SUCCESS && "address" in response) { + await safeCall( + () => getParent()?.onSessionCreated?.(), + "onSessionCreated", + ); + return { + code: ResponseCodes.SUCCESS as const, + address: response.address, + }; + } + + if ( + response.code === ResponseCodes.USER_INTERACTION_REQUIRED && + "requestId" in response + ) { + navigate(`/headless-approval/${response.requestId}`, { + replace: true, + }); + + await safeCall(() => getParent()?.open?.(), "open"); + await waitForApproval(response.requestId); + await safeCall( + () => getParent()?.onSessionCreated?.(), + "onSessionCreated", + ); + + const address = getConnectedAddress(); + if (!address) { + throw new Error("Controller not ready after approval"); + } + + return { + code: ResponseCodes.SUCCESS as const, + address, + }; + } + + // response is a ConnectError + throw response; + }; +} diff --git a/packages/keychain/src/utils/connection/connect.test.ts b/packages/keychain/src/utils/connection/connect.test.ts new file mode 100644 index 0000000000..2ab2952e3c --- /dev/null +++ b/packages/keychain/src/utils/connection/connect.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./callbacks", () => ({ + generateCallbackId: vi.fn(() => "test-id"), + storeCallbacks: vi.fn(), + getCallbacks: vi.fn(), +})); + +import type { + AuthOptions, + ConnectError, + ConnectOptions, + ConnectReply, +} from "@cartridge/controller"; +import { ResponseCodes } from "@cartridge/controller"; +import { createConnectUrl, parseConnectParams, connect } from "./connect"; +import { getCallbacks, storeCallbacks } from "./callbacks"; + +describe("connect utils", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createConnectUrl", () => { + it("creates a URL with just an id when no signup options are provided", () => { + const url = createConnectUrl(undefined); + expect(url).toBe("/connect?id=test-id"); + expect(storeCallbacks).not.toHaveBeenCalled(); + }); + + it("stores callbacks and encodes signup options", () => { + const resolve = vi.fn(); + const signers: AuthOptions = ["webauthn"]; + const url = createConnectUrl(signers, { resolve }); + + expect(storeCallbacks).toHaveBeenCalledTimes(1); + expect(url).toMatch(/^\/connect\?/); + + const searchParams = new URLSearchParams(url.split("?")[1]); + expect(searchParams.get("id")).toBe("test-id"); + expect(searchParams.get("signers")).toBe(JSON.stringify(signers)); + }); + }); + + describe("parseConnectParams", () => { + it("parses signers from a JSON array", () => { + vi.mocked(getCallbacks).mockReturnValue(undefined); + + const searchParams = new URLSearchParams(); + searchParams.set("id", "test-id"); + searchParams.set("signers", JSON.stringify(["webauthn"])); + + const parsed = parseConnectParams(searchParams); + expect(parsed?.params.signers).toEqual(["webauthn"]); + }); + + it("parses signupOptions from a JSON object payload", () => { + vi.mocked(getCallbacks).mockReturnValue(undefined); + + const searchParams = new URLSearchParams(); + searchParams.set("id", "test-id"); + searchParams.set( + "signers", + JSON.stringify({ + signupOptions: ["webauthn"], + } satisfies ConnectOptions), + ); + + const parsed = parseConnectParams(searchParams); + expect(parsed?.params.signers).toEqual(["webauthn"]); + }); + + it("wraps resolve to validate connect result type", () => { + const reject = vi.fn(); + const resolve = vi.fn(); + + vi.mocked(getCallbacks).mockReturnValue({ + resolve: resolve as unknown as (result: unknown) => void, + reject: reject as unknown as (error: unknown) => void, + }); + + const consoleError = vi.spyOn(console, "error").mockImplementation(() => { + // suppressed + }); + + const searchParams = new URLSearchParams(); + searchParams.set("id", "test-id"); + const parsed = parseConnectParams(searchParams); + expect(parsed).toBeTruthy(); + + // Invalid shape should reject. + parsed?.resolve?.({ not: "a connect result" }); + expect(reject).toHaveBeenCalledTimes(1); + + // Valid reply should resolve. + const ok: ConnectReply = { + code: ResponseCodes.SUCCESS, + address: "0xabc", + }; + parsed?.resolve?.(ok); + expect(resolve).toHaveBeenCalledWith(ok); + + consoleError.mockRestore(); + }); + }); + + describe("connect()", () => { + it("throws when signup options are an empty array", () => { + const navigate = vi.fn(); + const setRpcUrl = vi.fn(); + const connectFn = connect({ navigate, setRpcUrl })(); + expect(() => connectFn({ signupOptions: [] })).toThrow( + /signup options cannot be empty/i, + ); + }); + + it("navigates and resolves when callbacks resolve with an address", async () => { + const navigate = vi.fn(); + const setRpcUrl = vi.fn(); + const connectFn = connect({ navigate, setRpcUrl })(); + + const promise = connectFn({ signupOptions: ["webauthn"] }); + expect(navigate).toHaveBeenCalledWith( + expect.stringMatching(/^\/connect\?/), + { replace: true }, + ); + + const callbacks = vi.mocked(storeCallbacks).mock.calls[0][1] as { + resolve?: (result: ConnectReply | ConnectError) => void; + }; + callbacks.resolve?.({ code: ResponseCodes.SUCCESS, address: "0xabc" }); + + await expect(promise).resolves.toEqual({ + code: ResponseCodes.SUCCESS, + address: "0xabc", + }); + }); + }); +}); diff --git a/packages/keychain/src/utils/connection/connect.ts b/packages/keychain/src/utils/connection/connect.ts index f04f233ecc..784a96e7bb 100644 --- a/packages/keychain/src/utils/connection/connect.ts +++ b/packages/keychain/src/utils/connection/connect.ts @@ -1,4 +1,9 @@ -import { AuthOptions, ConnectError, ConnectReply } from "@cartridge/controller"; +import { + AuthOptions, + ConnectError, + ConnectReply, + ConnectOptions, +} from "@cartridge/controller"; import { SessionPolicies } from "@cartridge/presets"; import { generateCallbackId, storeCallbacks, getCallbacks } from "./callbacks"; @@ -68,7 +73,18 @@ export function parseConnectParams(searchParams: URLSearchParams): { const decoded = decodeURIComponent(signersParam); // Handle case where signupOptions was undefined and got stringified as "undefined" if (decoded !== "undefined" && decoded !== "null") { - signers = JSON.parse(decoded) as AuthOptions; + const parsed = JSON.parse(decoded) as AuthOptions | ConnectOptions; + if (Array.isArray(parsed)) { + signers = parsed as AuthOptions; + } else if (parsed && typeof parsed === "object") { + const maybeOptions = parsed as ConnectOptions; + if ( + "signupOptions" in maybeOptions && + Array.isArray(maybeOptions.signupOptions) + ) { + signers = maybeOptions.signupOptions; + } + } } } catch (e) { console.error("Failed to parse signers parameter:", e); @@ -130,25 +146,47 @@ export function connect({ return () => { // Support both old and new signatures for backwards compatibility // Old: connect(policies: SessionPolicies, rpcUrl: string, signupOptions?: AuthOptions) - // New: connect(signupOptions?: AuthOptions) + // New: connect(options?: ConnectOptions) return ( - policiesOrSigners?: SessionPolicies | AuthOptions, + policiesOrOptions?: SessionPolicies | AuthOptions | ConnectOptions, rpcUrl?: string, signupOptions?: AuthOptions, ): Promise => { let signers: AuthOptions | undefined; + const isValidUrl = (value: string) => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + }; // Detect which signature is being used - if (rpcUrl !== undefined) { + // Check if it's the old 3-parameter signature (policies, rpcUrl, signupOptions) + if ( + rpcUrl !== undefined && + typeof rpcUrl === "string" && + isValidUrl(rpcUrl) + ) { // 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; - // Set the RPC URL for backwards compatibility setRpcUrl(rpcUrl); + } else if ( + policiesOrOptions && + typeof policiesOrOptions === "object" && + !Array.isArray(policiesOrOptions) && + ("signupOptions" in policiesOrOptions || + "username" in policiesOrOptions || + "signer" in policiesOrOptions || + "password" in policiesOrOptions) + ) { + // New signature: connect(options: ConnectOptions) + const options = policiesOrOptions as ConnectOptions; + signers = options.signupOptions; } else { - // New signature: connect(signupOptions) - signers = policiesOrSigners as AuthOptions | undefined; + // Assume it's just AuthOptions passed directly (backwards compatibility) + signers = policiesOrOptions as AuthOptions | undefined; } if (signers && signers.length === 0) { diff --git a/packages/keychain/src/utils/connection/headless-requests.test.ts b/packages/keychain/src/utils/connection/headless-requests.test.ts new file mode 100644 index 0000000000..d2e3f89806 --- /dev/null +++ b/packages/keychain/src/utils/connection/headless-requests.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi, afterEach } from "vitest"; + +describe("headless-requests", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("resolves waiters when approval is resolved", async () => { + vi.resetModules(); + const mod = await import("./headless-requests"); + + const request = mod.createHeadlessApprovalRequest(); + const waiter = mod.waitForHeadlessApprovalRequest(request.id); + + mod.resolveHeadlessApprovalRequest(request.id); + + await expect(waiter).resolves.toBeUndefined(); + }); + + it("rejects waiters when approval expires", async () => { + vi.resetModules(); + const mod = await import("./headless-requests"); + + const request = mod.createHeadlessApprovalRequest(); + const waiter = mod.waitForHeadlessApprovalRequest(request.id); + const assertion = expect(waiter).rejects.toThrow(/expired/i); + + // TTL is 5 minutes. Advance past expiry and allow the timeout to run. + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 1); + await assertion; + }); + + it("getHeadlessApprovalRequest returns undefined after expiry", async () => { + vi.resetModules(); + const mod = await import("./headless-requests"); + + const request = mod.createHeadlessApprovalRequest(); + expect(mod.getHeadlessApprovalRequest(request.id)).toBeTruthy(); + + vi.setSystemTime(new Date(request.expiresAt + 1)); + expect(mod.getHeadlessApprovalRequest(request.id)).toBeUndefined(); + }); +}); diff --git a/packages/keychain/src/utils/connection/headless-requests.ts b/packages/keychain/src/utils/connection/headless-requests.ts new file mode 100644 index 0000000000..b0e9998c9c --- /dev/null +++ b/packages/keychain/src/utils/connection/headless-requests.ts @@ -0,0 +1,134 @@ +import { getPromiseWithResolvers } from "@/utils/promises"; +import { cleanupCallbacks, getCallbacks, storeCallbacks } from "./callbacks"; + +const HEADLESS_REQUEST_TTL_MS = 5 * 60 * 1000; + +export type HeadlessApprovalRequest = { + id: string; + createdAt: number; + expiresAt: number; +}; + +const pendingRequests = new Map(); + +type HeadlessApprovalWaiter = { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; + timeoutId?: ReturnType; +}; + +const HEADLESS_WAITER_KEY = "__cartridge_headless_waiter"; + +const generateRequestId = () => { + const prefix = "headless_"; + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return `${prefix}${crypto.randomUUID()}`; + } + + return `${prefix}${Math.random().toString(36).slice(2)}_${Date.now()}`; +}; + +const getWaiter = (id: string): HeadlessApprovalWaiter | undefined => { + const callbacks = getCallbacks(id) as Record | undefined; + return callbacks?.[HEADLESS_WAITER_KEY] as HeadlessApprovalWaiter | undefined; +}; + +const setWaiter = (id: string, waiter: HeadlessApprovalWaiter) => { + storeCallbacks(id, { + [HEADLESS_WAITER_KEY]: waiter, + }); +}; + +export const createHeadlessApprovalRequest = (): HeadlessApprovalRequest => { + const id = generateRequestId(); + const createdAt = Date.now(); + const request: HeadlessApprovalRequest = { + id, + createdAt, + expiresAt: createdAt + HEADLESS_REQUEST_TTL_MS, + }; + + pendingRequests.set(id, request); + return request; +}; + +export const getHeadlessApprovalRequest = (id: string) => { + const request = pendingRequests.get(id); + if (!request) { + return undefined; + } + + if (Date.now() > request.expiresAt) { + pendingRequests.delete(id); + return undefined; + } + + return request; +}; + +export const completeHeadlessApprovalRequest = (id: string) => { + pendingRequests.delete(id); +}; + +export const waitForHeadlessApprovalRequest = (id: string): Promise => { + const existing = getWaiter(id); + if (existing) { + return existing.promise; + } + + const request = getHeadlessApprovalRequest(id); + if (!request) { + return Promise.reject(new Error("Headless approval expired")); + } + + const { promise, resolve, reject } = getPromiseWithResolvers(); + const waiter: HeadlessApprovalWaiter = { promise, resolve, reject }; + setWaiter(id, waiter); + + const msUntilExpiry = Math.max(0, request.expiresAt - Date.now()); + const timeoutId = setTimeout(() => { + const pending = getWaiter(id); + if (!pending) return; + pending.reject(new Error("Headless approval expired")); + cleanupCallbacks(id); + }, msUntilExpiry); + + // Store the timeout id after creation (for clearTimeout on resolve/reject). + waiter.timeoutId = timeoutId; + + return promise; +}; + +export const resolveHeadlessApprovalRequest = (id: string) => { + const waiter = getWaiter(id); + if (!waiter) return; + if (waiter.timeoutId) { + clearTimeout(waiter.timeoutId); + } + waiter.resolve(); + cleanupCallbacks(id); +}; + +export const rejectHeadlessApprovalRequest = (id: string, error: Error) => { + const waiter = getWaiter(id); + if (!waiter) return; + if (waiter.timeoutId) { + clearTimeout(waiter.timeoutId); + } + waiter.reject(error); + cleanupCallbacks(id); +}; + +const cleanupExpiredRequests = () => { + for (const [id, request] of pendingRequests.entries()) { + if (Date.now() > request.expiresAt) { + pendingRequests.delete(id); + } + } +}; + +export const hasPendingHeadlessApproval = () => { + cleanupExpiredRequests(); + return pendingRequests.size > 0; +}; diff --git a/packages/keychain/src/utils/connection/headless.ts b/packages/keychain/src/utils/connection/headless.ts new file mode 100644 index 0000000000..f69735a67f --- /dev/null +++ b/packages/keychain/src/utils/connection/headless.ts @@ -0,0 +1,447 @@ +import { fetchController } from "@/components/connect/create/utils"; +import { decryptPrivateKey } from "@/components/connect/create/password/crypto"; +import { DEFAULT_SESSION_DURATION, now } from "@/constants"; +import { ParsedSessionPolicies } from "@/hooks/session"; +import { TurnkeyWallet } from "@/wallets/social/turnkey"; +import { SocialProvider } from "@/wallets/social/turnkey_utils"; +import { WalletConnectWallet } from "@/wallets/wallet-connect"; +import Controller from "@/utils/controller"; +import { + AuthOption, + ExternalWalletResponse, + ExternalWalletType, + HeadlessConnectOptions, + HeadlessConnectReply, + ResponseCodes, + WalletAdapter, +} from "@cartridge/controller"; +import { Owner } from "@cartridge/controller-wasm"; +import { + Eip191Credentials, + PasswordCredentials, + WebauthnCredentials, + type ControllerQuery, +} from "@cartridge/ui/utils/api/cartridge"; +import { getAddress } from "ethers"; +import { + createHeadlessApprovalRequest, + hasPendingHeadlessApproval, +} from "./headless-requests"; +import { + createVerifiedSession, + requiresSessionApproval, +} from "./session-creation"; + +export type HeadlessConnectionState = { + origin?: string; + chainId?: string; + rpcUrl: string; + policies?: ParsedSessionPolicies; + isPoliciesResolved: boolean; + isConfigLoading: boolean; +}; + +type ControllerSigners = NonNullable< + NonNullable["signers"] +>; + +export type HeadlessConnectParent = { + open?: () => void | Promise; + close?: () => void | Promise; + onSessionCreated?: () => void | Promise; + externalConnectWallet: ( + type: ExternalWalletType, + ) => Promise; +}; + +type HeadlessConnectDependencies = { + setController: (controller?: Controller) => void; + getParent: () => Parent | undefined; + getConnectionState: () => HeadlessConnectionState; +}; + +const waitForConnectionReady = async ( + getConnectionState: () => HeadlessConnectionState, +) => { + const timeoutMs = 10_000; + const pollIntervalMs = 100; + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const state = getConnectionState(); + if ( + state.origin && + state.chainId && + state.isPoliciesResolved && + !state.isConfigLoading + ) { + return state; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return getConnectionState(); +}; + +const buildWebauthnOwner = (signers?: ControllerSigners): Owner => { + const credential = signers?.find( + (signer) => signer.metadata.__typename === "WebauthnCredentials", + )?.metadata as WebauthnCredentials | undefined; + const webauthnCredential = credential?.webauthn?.[0]; + + if (!webauthnCredential) { + throw new Error("WebAuthn signer not found for controller"); + } + + return { + signer: { + webauthn: { + rpId: import.meta.env.VITE_RP_ID!, + credentialId: webauthnCredential.id ?? "", + publicKey: webauthnCredential.publicKey ?? "", + }, + }, + }; +}; + +const buildPasswordOwner = async ( + signers: ControllerSigners | undefined, + password?: string, +): Promise => { + if (!password) { + throw new Error("Password required for password authentication"); + } + + const credential = signers?.find( + (signer) => signer.metadata.__typename === "PasswordCredentials", + )?.metadata as PasswordCredentials | undefined; + const encryptedPrivateKey = credential?.password?.[0]?.encryptedPrivateKey; + + if (!encryptedPrivateKey) { + throw new Error("Password signer not found for controller"); + } + + const privateKey = await decryptPrivateKey(encryptedPrivateKey, password); + + return { + signer: { + starknet: { + privateKey, + }, + }, + }; +}; + +const hasMatchingEip191Signer = ({ + signers, + provider, + address, +}: { + signers: ControllerSigners | undefined; + provider?: string; + address: string; +}) => { + if (!signers) { + return false; + } + + let normalizedAddress: string; + try { + normalizedAddress = getAddress(address); + } catch { + return false; + } + + return signers.some((signer) => { + if (signer.metadata.__typename !== "Eip191Credentials") { + return false; + } + + const metadata = signer.metadata as Eip191Credentials; + return ( + metadata.eip191?.some((credential) => { + if (provider && credential.provider !== provider) { + return false; + } + + try { + return getAddress(credential.ethAddress) === normalizedAddress; + } catch { + return false; + } + }) ?? false + ); + }); +}; + +const buildEip191Owner = async ({ + signers, + provider, + connectWallet, +}: { + signers: ControllerSigners | undefined; + provider: ExternalWalletType; + connectWallet: (type: ExternalWalletType) => Promise; +}): Promise => { + const response = await connectWallet(provider); + if (!response.success || !response.account) { + throw new Error(response.error || "Failed to connect wallet"); + } + + if ( + !hasMatchingEip191Signer({ + signers, + provider, + address: response.account, + }) + ) { + throw new Error("Connected wallet does not match controller signer"); + } + + return { + signer: { + eip191: { + address: getAddress(response.account), + }, + }, + }; +}; + +const buildWalletConnectOwner = async ( + signers: ControllerSigners | undefined, +): Promise => { + const walletConnectWallet = new WalletConnectWallet(); + const { success, account, error } = + (await walletConnectWallet.connect()) as ExternalWalletResponse; + + if (!success || !account) { + throw new Error("Failed to connect to WalletConnect: " + (error ?? "")); + } + + window.keychain_wallets?.addEmbeddedWallet( + account, + walletConnectWallet as WalletAdapter, + ); + + if ( + !hasMatchingEip191Signer({ + signers, + address: account, + }) + ) { + throw new Error("Connected wallet does not match controller signer"); + } + + return { + signer: { + eip191: { + address: account, + }, + }, + }; +}; + +const buildSocialOwner = async ({ + username, + chainId, + rpcUrl, + provider, + signers, +}: { + username: string; + chainId: string; + rpcUrl: string; + provider: SocialProvider; + signers: ControllerSigners | undefined; +}): Promise => { + const turnkeyWallet = new TurnkeyWallet(username, chainId, rpcUrl, provider); + const { account, error, success } = await turnkeyWallet.connect(false); + + if (!success || !account) { + throw new Error(error || "Failed to connect to Turnkey"); + } + + window.keychain_wallets?.addEmbeddedWallet( + account, + turnkeyWallet as unknown as WalletAdapter, + ); + + if ( + !hasMatchingEip191Signer({ + signers, + address: account, + }) + ) { + throw new Error("Connected wallet does not match controller signer"); + } + + return { + signer: { + eip191: { + address: account, + }, + }, + }; +}; + +const loginWithSigner = async ({ + username, + signer, + password, + origin, + chainId, + rpcUrl, + getParent, +}: { + username: string; + signer: AuthOption; + password?: string; + origin: string; + chainId: string; + rpcUrl: string; + getParent: () => HeadlessConnectParent | undefined; +}) => { + const controllerData = await fetchController(chainId, username); + const controllerQuery = controllerData?.controller; + + if (!controllerQuery) { + throw new Error("Controller not found"); + } + + const signers = controllerQuery.signers ?? undefined; + + let owner: Owner; + + switch (signer) { + case "webauthn": + owner = buildWebauthnOwner(signers); + break; + case "password": + owner = await buildPasswordOwner(signers, password); + break; + case "metamask": + case "rabby": + case "phantom-evm": { + const parent = getParent(); + if (!parent) { + throw new Error("Wallet connection not ready"); + } + owner = await buildEip191Owner({ + signers, + provider: signer, + connectWallet: parent.externalConnectWallet, + }); + break; + } + case "walletconnect": + owner = await buildWalletConnectOwner(signers); + break; + case "google": + case "discord": + owner = await buildSocialOwner({ + username, + chainId, + rpcUrl, + provider: signer, + signers, + }); + break; + default: + throw new Error(`Unsupported headless signer: ${signer}`); + } + + const loginRet = await Controller.login({ + appId: origin, + classHash: controllerQuery.constructorCalldata[0], + rpcUrl, + address: controllerQuery.address, + username: controllerQuery.accountID, + owner, + cartridgeApiUrl: import.meta.env.VITE_CARTRIDGE_API_URL, + session_expires_at_s: Number(now() + DEFAULT_SESSION_DURATION), + isControllerRegistered: true, + }); + + return loginRet.controller; +}; + +export const headlessConnect = + ({ + setController, + getParent, + getConnectionState, + }: HeadlessConnectDependencies) => + (origin: string) => + async (options: HeadlessConnectOptions): Promise => { + if (!options?.username || !options?.signer) { + return { + code: ResponseCodes.ERROR, + message: "Headless connect requires username and signer", + }; + } + + if (hasPendingHeadlessApproval()) { + return { + code: ResponseCodes.ERROR, + message: "A headless approval is already pending", + }; + } + + const state = await waitForConnectionReady(getConnectionState); + const effectiveOrigin = state.origin ?? origin; + if (!effectiveOrigin || !state.chainId) { + return { + code: ResponseCodes.ERROR, + message: "Connection is not ready", + }; + } + + try { + const controller = await loginWithSigner({ + username: options.username, + signer: options.signer, + password: options.password, + origin: effectiveOrigin, + chainId: state.chainId, + rpcUrl: state.rpcUrl, + getParent, + }); + + window.controller = controller; + setController(controller); + + if (!state.policies) { + return { + code: ResponseCodes.SUCCESS, + address: controller.address(), + }; + } + + if (!requiresSessionApproval(state.policies)) { + await createVerifiedSession({ + controller, + origin: effectiveOrigin, + policies: state.policies, + }); + return { + code: ResponseCodes.SUCCESS, + address: controller.address(), + }; + } + + const request = createHeadlessApprovalRequest(); + + return { + code: ResponseCodes.USER_INTERACTION_REQUIRED, + requestId: request.id, + }; + } catch (error) { + return { + code: ResponseCodes.ERROR, + message: + error instanceof Error + ? error.message + : "Headless authentication failed", + }; + } + }; diff --git a/packages/keychain/src/utils/connection/index.ts b/packages/keychain/src/utils/connection/index.ts index 04e2ab1922..6154638e40 100644 --- a/packages/keychain/src/utils/connection/index.ts +++ b/packages/keychain/src/utils/connection/index.ts @@ -5,21 +5,37 @@ import { connect } from "./connect"; import { deployFactory } from "./deploy"; import { estimateInvokeFee } from "./estimate"; import { execute } from "./execute"; +import type { + HeadlessConnectParent, + HeadlessConnectionState, +} from "./headless"; +import { headlessConnect } from "./headless"; import { probe } from "./probe"; import { openSettingsFactory } from "./settings"; import { signMessageFactory } from "./sign"; import { switchChain } from "./switchChain"; import { navigateFactory } from "./navigate"; -import { StarterpackOptions } from "@cartridge/controller"; +import type { + AuthOptions, + ConnectOptions, + SessionPolicies, + StarterpackOptions, +} from "@cartridge/controller"; +import { waitForHeadlessApprovalRequest } from "./headless-requests"; +import { createConnectHandler } from "./connect-routing"; export type { ControllerError } from "./execute"; -export function connectToController({ +export function connectToController< + ParentMethods extends HeadlessConnectParent, +>({ setRpcUrl, setController, navigate, propagateError, errorDisplayMode, + getParent, + getConnectionState, }: { setRpcUrl: (url: string) => void; setController: (controller?: Controller) => void; @@ -29,15 +45,43 @@ export function connectToController({ ) => void; propagateError?: boolean; errorDisplayMode?: "modal" | "notification" | "silent"; + getParent: () => ParentMethods | undefined; + getConnectionState: () => HeadlessConnectionState; }) { + const uiConnect = connect({ + navigate, + setRpcUrl, + }); + + const headlessConnectImpl = headlessConnect({ + setController, + getParent, + getConnectionState, + }); + return connectToParent({ methods: { - connect: normalize( - connect({ - navigate, - setRpcUrl, - }), - ), + connect: normalize((origin) => { + const uiConnectFn = uiConnect(); + const headlessConnectFn = headlessConnectImpl(origin); + + return async ( + policiesOrOptions?: SessionPolicies | AuthOptions | ConnectOptions, + rpcUrl?: string, + signupOptions?: AuthOptions, + ) => { + const handler = createConnectHandler({ + uiConnect: uiConnectFn, + headlessConnect: headlessConnectFn, + navigate, + getParent, + waitForApproval: waitForHeadlessApprovalRequest, + getConnectedAddress: () => window.controller?.address?.(), + }); + + return handler(policiesOrOptions, rpcUrl, signupOptions); + }; + }), deploy: () => deployFactory({ navigate }), execute: normalize( execute({ navigate, propagateError, errorDisplayMode }), diff --git a/packages/keychain/src/utils/connection/session-creation.test.ts b/packages/keychain/src/utils/connection/session-creation.test.ts new file mode 100644 index 0000000000..cf68bbd129 --- /dev/null +++ b/packages/keychain/src/utils/connection/session-creation.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; +import type Controller from "@/utils/controller"; +import type { ParsedSessionPolicies } from "@/hooks/session"; +import { + createVerifiedSession, + requiresSessionApproval, + DEFAULT_VERIFIED_SESSION_DURATION_S, +} from "./session-creation"; + +describe("session-creation helpers", () => { + describe("requiresSessionApproval", () => { + it("returns false when no policies are provided", () => { + expect(requiresSessionApproval(undefined)).toBe(false); + expect(requiresSessionApproval(null)).toBe(false); + }); + + it("returns true for unverified policies", () => { + const policies = { + verified: false, + contracts: {}, + } as unknown as ParsedSessionPolicies; + expect(requiresSessionApproval(policies)).toBe(true); + }); + + it("returns true for verified policies that include approvals", () => { + const policies = { + verified: true, + contracts: { + "0x1": { + methods: [{ entrypoint: "approve" }], + }, + }, + } as unknown as ParsedSessionPolicies; + expect(requiresSessionApproval(policies)).toBe(true); + }); + + it("returns false for verified policies without approvals", () => { + const policies = { + verified: true, + contracts: { + "0x1": { + methods: [{ entrypoint: "transfer" }], + }, + }, + } as unknown as ParsedSessionPolicies; + expect(requiresSessionApproval(policies)).toBe(false); + }); + }); + + describe("createVerifiedSession", () => { + it("throws when policies require approval", async () => { + const controller = { + createSession: vi.fn(), + } as unknown as Controller; + + await expect( + createVerifiedSession({ + controller, + origin: "app", + policies: { verified: false } as unknown as ParsedSessionPolicies, + }), + ).rejects.toThrow(/requires explicit approval/i); + }); + + it("creates a session with processed policies and deterministic expiry", async () => { + const createSession = vi.fn().mockResolvedValue(undefined); + const controller = { createSession } as unknown as Controller; + + const policies = { + verified: true, + contracts: { + "0xabc": { + methods: [ + { + entrypoint: "transfer", + id: "method-id", + authorized: false, + }, + ], + }, + }, + messages: [{ id: "msg-id", authorized: false }], + } as unknown as ParsedSessionPolicies; + + const nowFn = () => BigInt(1_000); + const durationSeconds = DEFAULT_VERIFIED_SESSION_DURATION_S; + + await createVerifiedSession({ + controller, + origin: "origin", + policies, + nowFn, + durationSeconds, + }); + + expect(createSession).toHaveBeenCalledTimes(1); + const [origin, expiresAt, processedPolicies] = + createSession.mock.calls[0]; + + expect(origin).toBe("origin"); + expect(expiresAt).toBe(BigInt(1_000) + durationSeconds); + + // UI-only fields are stripped and policies are forced authorized. + expect( + processedPolicies.contracts["0xabc"].methods[0].id, + ).toBeUndefined(); + expect(processedPolicies.contracts["0xabc"].methods[0].authorized).toBe( + true, + ); + expect(processedPolicies.messages[0].id).toBeUndefined(); + expect(processedPolicies.messages[0].authorized).toBe(true); + }); + }); +}); diff --git a/packages/keychain/src/utils/connection/session-creation.ts b/packages/keychain/src/utils/connection/session-creation.ts new file mode 100644 index 0000000000..3a3aacd167 --- /dev/null +++ b/packages/keychain/src/utils/connection/session-creation.ts @@ -0,0 +1,53 @@ +import type Controller from "@/utils/controller"; +import { now } from "@/constants"; +import { + hasApprovalPolicies, + type ParsedSessionPolicies, +} from "@/hooks/session"; +import { processPolicies } from "@/utils/session/policies"; + +export const DEFAULT_VERIFIED_SESSION_DURATION_S = BigInt(24 * 60 * 60); + +export function requiresSessionApproval( + policies?: ParsedSessionPolicies | null, +): boolean { + if (!policies) { + return false; + } + + // Unverified policies always require explicit user consent. + if (!policies.verified) { + return true; + } + + // Even when policies are verified, token approvals should always require UI review. + return hasApprovalPolicies(policies); +} + +export function canAutoCreateSession( + policies?: ParsedSessionPolicies | null, +): boolean { + return !policies || !requiresSessionApproval(policies); +} + +export async function createVerifiedSession({ + controller, + origin, + policies, + durationSeconds = DEFAULT_VERIFIED_SESSION_DURATION_S, + nowFn = now, +}: { + controller: Controller; + origin: string; + policies: ParsedSessionPolicies; + durationSeconds?: bigint; + nowFn?: () => bigint; +}): Promise { + if (requiresSessionApproval(policies)) { + throw new Error("Verified session creation requires explicit approval"); + } + + const expiresAt = durationSeconds + nowFn(); + const processedPolicies = processPolicies(policies, false); + await controller.createSession(origin, expiresAt, processedPolicies); +} diff --git a/packages/keychain/src/utils/session/policies.ts b/packages/keychain/src/utils/session/policies.ts new file mode 100644 index 0000000000..3ee2549579 --- /dev/null +++ b/packages/keychain/src/utils/session/policies.ts @@ -0,0 +1,38 @@ +import type { ParsedSessionPolicies } from "@/hooks/session"; + +/** + * Deep copy the policies and remove UI-only fields. + * + * IMPORTANT: + * - This intentionally strips `id` fields (used only for UI rendering). + * - When `toggleOff` is provided, it forces all policies to authorized/unauthorized + * to support "Skip" flows. + */ +export function processPolicies( + policies: ParsedSessionPolicies, + toggleOff?: boolean, +): ParsedSessionPolicies { + const processed: ParsedSessionPolicies = JSON.parse(JSON.stringify(policies)); + + if (processed.contracts) { + Object.values(processed.contracts).forEach((contract) => { + contract.methods.forEach((method) => { + delete method.id; + if (toggleOff !== undefined) { + method.authorized = !toggleOff; + } + }); + }); + } + + if (processed.messages) { + processed.messages.forEach((message) => { + delete message.id; + if (toggleOff !== undefined) { + message.authorized = !toggleOff; + } + }); + } + + return processed; +} diff --git a/packages/keychain/vitest.config.ts b/packages/keychain/vitest.config.ts index bf5fbe2fc3..ab5f5b4f93 100644 --- a/packages/keychain/vitest.config.ts +++ b/packages/keychain/vitest.config.ts @@ -56,13 +56,18 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "lcov"], + // Limit coverage to source files. This avoids counting build outputs/configs + // (dist/, public/, vite config, etc.) which are not meaningful for unit tests. + include: ["src/**/*.{ts,tsx}"], exclude: [ "node_modules/**", "src/test/**", "**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}", "**/*.stories.{ts,tsx}", + "**/*.d.ts", "src/**/__mocks__/**", + "src/utils/api/generated.ts", ], }, server: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d65bbfcb49..84f221c482 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,16 +169,16 @@ importers: version: https://codeload.github.com/cartridge-gg/ui/tar.gz/e5405d6(@types/react-dom@18.3.7(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(starknet@8.5.4)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@16.18.11)(typescript@5.8.3)))(viem@2.28.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.4)) '@graphql-codegen/cli': specifier: ^2.6.2 - version: 2.16.5(@babel/core@7.27.1)(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.11.0)(typescript@5.8.3)(utf-8-validate@5.0.10) + version: 2.16.5(@babel/core@7.27.1)(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.12.0)(typescript@5.8.3)(utf-8-validate@5.0.10) '@graphql-codegen/typescript': specifier: ^2.4.8 - version: 2.8.8(encoding@0.1.13)(graphql@16.11.0) + version: 2.8.8(encoding@0.1.13)(graphql@16.12.0) '@graphql-codegen/typescript-operations': specifier: ^2.3.5 - version: 2.5.13(encoding@0.1.13)(graphql@16.11.0) + version: 2.5.13(encoding@0.1.13)(graphql@16.12.0) '@graphql-codegen/typescript-react-query': specifier: ^3.5.9 - version: 3.6.2(encoding@0.1.13)(graphql@16.11.0) + version: 3.6.2(encoding@0.1.13)(graphql@16.12.0) tailwindcss: specifier: 'catalog:' version: 3.4.17(ts-node@10.9.2(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@16.18.11)(typescript@5.8.3)) @@ -381,7 +381,7 @@ importers: version: 5.28.2 svelte-check: specifier: ^4.1.4 - version: 4.1.6(picomatch@4.0.2)(svelte@5.28.2)(typescript@5.8.3) + version: 4.1.6(picomatch@4.0.3)(svelte@5.28.2)(typescript@5.8.3) typescript: specifier: ^5.7.3 version: 5.8.3 @@ -410,6 +410,9 @@ importers: '@cartridge/tsconfig': specifier: workspace:* version: link:../tsconfig + '@vitest/coverage-v8': + specifier: 2.1.8 + version: 2.1.8(vitest@2.1.8(@edge-runtime/vm@3.2.0)(@types/node@22.15.3)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.7(@types/node@22.15.3)(typescript@5.8.3))(terser@5.44.1)) prettier: specifier: 'catalog:' version: 3.5.3 @@ -419,6 +422,9 @@ importers: typescript: specifier: ^5.7.3 version: 5.8.3 + vitest: + specifier: 2.1.8 + version: 2.1.8(@edge-runtime/vm@3.2.0)(@types/node@22.15.3)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.7(@types/node@22.15.3)(typescript@5.8.3))(terser@5.44.1) packages/controller: dependencies: @@ -633,7 +639,7 @@ importers: version: 3.1.3 graphql-request: specifier: ^5.0.0 - version: 5.2.0(encoding@0.1.13)(graphql@16.11.0) + version: 5.2.0(encoding@0.1.13)(graphql@16.12.0) history: specifier: ^5.3.0 version: 5.3.0 @@ -739,7 +745,7 @@ importers: version: 3.9.0(@swc/helpers@0.5.17)(vite@6.3.4(@types/node@18.19.87)(jiti@1.21.7)(terser@5.44.1)(tsx@4.19.4)(yaml@2.7.1)) '@vitest/coverage-v8': specifier: 2.1.8 - version: 2.1.8(vitest@2.1.8(@edge-runtime/vm@3.2.0)(@types/node@18.19.87)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.44.1)) + version: 2.1.8(vitest@2.1.8(@edge-runtime/vm@3.2.0)(@types/node@18.19.87)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.7(@types/node@18.19.87)(typescript@5.8.3))(terser@5.44.1)) autoprefixer: specifier: 'catalog:' version: 10.4.21(postcss@8.5.3) @@ -796,7 +802,7 @@ importers: version: 3.4.1(vite@6.3.4(@types/node@18.19.87)(jiti@1.21.7)(terser@5.44.1)(tsx@4.19.4)(yaml@2.7.1)) vitest: specifier: 2.1.8 - version: 2.1.8(@edge-runtime/vm@3.2.0)(@types/node@18.19.87)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.44.1) + version: 2.1.8(@edge-runtime/vm@3.2.0)(@types/node@18.19.87)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.7(@types/node@18.19.87)(typescript@5.8.3))(terser@5.44.1) ws: specifier: ^8.18.3 version: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -2197,6 +2203,28 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/external-editor@1.0.2': resolution: {integrity: sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==} engines: {node: '>=18'} @@ -2206,6 +2234,19 @@ packages: '@types/node': optional: true + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@ionic/cli-framework-output@2.2.8': resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==} engines: {node: '>=16.0.0'} @@ -2438,6 +2479,10 @@ packages: cpu: [x64] os: [win32] + '@mswjs/interceptors@0.40.0': + resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} + engines: {node: '>=18'} + '@mui/core-downloads-tracker@6.4.11': resolution: {integrity: sha512-CzAQs9CTzlwbsF9ZYB4o4lLwBv1/qNE264NjuYao+ctAXsmlPtYa8RtER4UsUXSMxNN9Qi+aQdYcKl2sUpnmAw==} @@ -2625,6 +2670,15 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -4217,6 +4271,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -5320,6 +5377,10 @@ packages: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -5470,6 +5531,10 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -6633,15 +6698,17 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-modules@0.2.3: resolution: {integrity: sha512-JeXuCbvYzYXcwE6acL9V2bAOeSIGl4dD+iwLY9iUx2VBJJ80R18HCn+JCwHM9Oegdfya3lEkGCdaRkSyc10hDA==} @@ -6719,8 +6786,8 @@ packages: peerDependencies: graphql: '>=0.11 <=16' - graphql@16.11.0: - resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} + graphql@16.12.0: + resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} grpc-web@1.5.0: @@ -6781,6 +6848,9 @@ packages: header-case@2.0.4: resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + history@5.3.0: resolution: {integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==} @@ -7063,6 +7133,9 @@ packages: resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} engines: {node: '>= 0.4'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -7884,6 +7957,16 @@ packages: msgpackr@1.11.8: resolution: {integrity: sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==} + msw@2.12.7: + resolution: {integrity: sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: ^5.7.3 + peerDependenciesMeta: + typescript: + optional: true + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -7896,6 +7979,10 @@ packages: mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -8144,6 +8231,9 @@ packages: resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} engines: {node: '>= 6.0'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -8300,6 +8390,9 @@ packages: path-to-regexp@6.2.1: resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -8338,6 +8431,10 @@ packages: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -8874,6 +8971,9 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + rettime@0.7.0: + resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -9221,6 +9321,10 @@ packages: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -9259,6 +9363,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} @@ -9416,6 +9523,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} @@ -9509,10 +9620,17 @@ packages: tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -9532,6 +9650,10 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -9730,6 +9852,10 @@ packages: resolution: {integrity: sha512-9YvLNnORDpI+vghLU/Nf+zSv0kL47KbVJ1o3sKgoTefl6i+zebxbiDQWoe/oWWqPhIgQdRZRT1KA9sCPL810SA==} engines: {node: '>=16'} + type-fest@5.4.2: + resolution: {integrity: sha512-FLEenlVYf7Zcd34ISMLo3ZzRE1gRjY1nMDTp+bQRBiPsaKyIW8K3Zr99ioHDUgA9OGuGGJPyYpNcffGmBhJfGg==} + engines: {node: '>=20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -9891,6 +10017,9 @@ packages: uploadthing: optional: true + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + untildify@4.0.0: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'} @@ -10234,10 +10363,12 @@ packages: whatwg-encoding@2.0.0: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -10481,6 +10612,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} @@ -10537,23 +10672,23 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - '@ardatan/relay-compiler@12.0.0(encoding@0.1.13)(graphql@16.11.0)': + '@ardatan/relay-compiler@12.0.0(encoding@0.1.13)(graphql@16.12.0)': dependencies: '@babel/core': 7.27.1 '@babel/generator': 7.27.1 - '@babel/parser': 7.27.2 + '@babel/parser': 7.28.4 '@babel/runtime': 7.28.4 '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 + '@babel/types': 7.28.4 babel-preset-fbjs: 3.4.0(@babel/core@7.27.1) chalk: 4.1.2 fb-watchman: 2.0.2 fbjs: 3.0.5(encoding@0.1.13) glob: 7.2.3 - graphql: 16.11.0 + graphql: 16.12.0 immutable: 3.7.6 invariant: 2.2.4 nullthrows: 1.1.1 @@ -10660,14 +10795,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.27.1': dependencies: '@babel/traverse': 7.28.4 - '@babel/types': 7.27.1 + '@babel/types': 7.28.4 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 + '@babel/types': 7.28.4 transitivePeerDependencies: - supports-color @@ -10682,7 +10817,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.27.1 + '@babel/types': 7.28.4 '@babel/helper-plugin-utils@7.27.1': {} @@ -10698,7 +10833,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 + '@babel/types': 7.28.4 transitivePeerDependencies: - supports-color @@ -10945,7 +11080,7 @@ snapshots: '@babel/helper-module-imports': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) - '@babel/types': 7.27.1 + '@babel/types': 7.28.4 transitivePeerDependencies: - supports-color @@ -10981,9 +11116,9 @@ snapshots: dependencies: '@babel/code-frame': 7.27.1 '@babel/generator': 7.27.1 - '@babel/parser': 7.27.2 + '@babel/parser': 7.28.4 '@babel/template': 7.27.2 - '@babel/types': 7.27.1 + '@babel/types': 7.28.4 debug: 4.4.3 globals: 11.12.0 transitivePeerDependencies: @@ -11644,7 +11779,7 @@ snapshots: '@eslint/config-array@0.20.0': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.0 + debug: 4.4.3 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -11658,7 +11793,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.0 + debug: 4.4.3 espree: 10.3.0 globals: 14.0.0 ignore: 5.3.2 @@ -11697,23 +11832,23 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@graphql-codegen/cli@2.16.5(@babel/core@7.27.1)(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.11.0)(typescript@5.8.3)(utf-8-validate@5.0.10)': + '@graphql-codegen/cli@2.16.5(@babel/core@7.27.1)(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.12.0)(typescript@5.8.3)(utf-8-validate@5.0.10)': dependencies: '@babel/generator': 7.27.1 '@babel/template': 7.27.2 '@babel/types': 7.27.1 - '@graphql-codegen/core': 2.6.8(graphql@16.11.0) - '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.11.0) - '@graphql-tools/apollo-engine-loader': 7.3.26(encoding@0.1.13)(graphql@16.11.0) - '@graphql-tools/code-file-loader': 7.3.23(@babel/core@7.27.1)(graphql@16.11.0) - '@graphql-tools/git-loader': 7.3.0(@babel/core@7.27.1)(graphql@16.11.0) - '@graphql-tools/github-loader': 7.3.28(@babel/core@7.27.1)(@types/node@16.18.11)(encoding@0.1.13)(graphql@16.11.0) - '@graphql-tools/graphql-file-loader': 7.5.17(graphql@16.11.0) - '@graphql-tools/json-file-loader': 7.4.18(graphql@16.11.0) - '@graphql-tools/load': 7.8.14(graphql@16.11.0) - '@graphql-tools/prisma-loader': 7.2.72(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.11.0)(utf-8-validate@5.0.10) - '@graphql-tools/url-loader': 7.17.18(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.11.0)(utf-8-validate@5.0.10) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-codegen/core': 2.6.8(graphql@16.12.0) + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.12.0) + '@graphql-tools/apollo-engine-loader': 7.3.26(encoding@0.1.13)(graphql@16.12.0) + '@graphql-tools/code-file-loader': 7.3.23(@babel/core@7.27.1)(graphql@16.12.0) + '@graphql-tools/git-loader': 7.3.0(@babel/core@7.27.1)(graphql@16.12.0) + '@graphql-tools/github-loader': 7.3.28(@babel/core@7.27.1)(@types/node@16.18.11)(encoding@0.1.13)(graphql@16.12.0) + '@graphql-tools/graphql-file-loader': 7.5.17(graphql@16.12.0) + '@graphql-tools/json-file-loader': 7.4.18(graphql@16.12.0) + '@graphql-tools/load': 7.8.14(graphql@16.12.0) + '@graphql-tools/prisma-loader': 7.2.72(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.12.0)(utf-8-validate@5.0.10) + '@graphql-tools/url-loader': 7.17.18(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.12.0)(utf-8-validate@5.0.10) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) '@whatwg-node/fetch': 0.6.9(@types/node@16.18.11) chalk: 4.1.2 chokidar: 3.6.0 @@ -11721,8 +11856,8 @@ snapshots: cosmiconfig-typescript-loader: 4.4.0(@types/node@16.18.11)(cosmiconfig@7.1.0)(ts-node@10.9.2(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@16.18.11)(typescript@5.8.3))(typescript@5.8.3) debounce: 1.2.1 detect-indent: 6.1.0 - graphql: 16.11.0 - graphql-config: 4.5.0(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.11.0)(utf-8-validate@5.0.10) + graphql: 16.12.0 + graphql-config: 4.5.0(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.12.0)(utf-8-validate@5.0.10) inquirer: 8.2.7(@types/node@16.18.11) is-glob: 4.0.3 json-to-pretty-yaml: 1.2.2 @@ -11748,159 +11883,159 @@ snapshots: - typescript - utf-8-validate - '@graphql-codegen/core@2.6.8(graphql@16.11.0)': + '@graphql-codegen/core@2.6.8(graphql@16.12.0)': dependencies: - '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.11.0) - '@graphql-tools/schema': 9.0.19(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - graphql: 16.11.0 + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.12.0) + '@graphql-tools/schema': 9.0.19(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + graphql: 16.12.0 tslib: 2.4.1 - '@graphql-codegen/plugin-helpers@2.7.2(graphql@16.11.0)': + '@graphql-codegen/plugin-helpers@2.7.2(graphql@16.12.0)': dependencies: - '@graphql-tools/utils': 8.13.1(graphql@16.11.0) + '@graphql-tools/utils': 8.13.1(graphql@16.12.0) change-case-all: 1.0.14 common-tags: 1.8.2 - graphql: 16.11.0 + graphql: 16.12.0 import-from: 4.0.0 lodash: 4.17.21 tslib: 2.4.1 - '@graphql-codegen/plugin-helpers@3.1.2(graphql@16.11.0)': + '@graphql-codegen/plugin-helpers@3.1.2(graphql@16.12.0)': dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) change-case-all: 1.0.15 common-tags: 1.8.2 - graphql: 16.11.0 + graphql: 16.12.0 import-from: 4.0.0 lodash: 4.17.21 tslib: 2.4.1 - '@graphql-codegen/schema-ast@2.6.1(graphql@16.11.0)': + '@graphql-codegen/schema-ast@2.6.1(graphql@16.12.0)': dependencies: - '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - graphql: 16.11.0 + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + graphql: 16.12.0 tslib: 2.4.1 - '@graphql-codegen/typescript-operations@2.5.13(encoding@0.1.13)(graphql@16.11.0)': + '@graphql-codegen/typescript-operations@2.5.13(encoding@0.1.13)(graphql@16.12.0)': dependencies: - '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.11.0) - '@graphql-codegen/typescript': 2.8.8(encoding@0.1.13)(graphql@16.11.0) - '@graphql-codegen/visitor-plugin-common': 2.13.8(encoding@0.1.13)(graphql@16.11.0) + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.12.0) + '@graphql-codegen/typescript': 2.8.8(encoding@0.1.13)(graphql@16.12.0) + '@graphql-codegen/visitor-plugin-common': 2.13.8(encoding@0.1.13)(graphql@16.12.0) auto-bind: 4.0.0 - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.4.1 transitivePeerDependencies: - encoding - supports-color - '@graphql-codegen/typescript-react-query@3.6.2(encoding@0.1.13)(graphql@16.11.0)': + '@graphql-codegen/typescript-react-query@3.6.2(encoding@0.1.13)(graphql@16.12.0)': dependencies: - '@graphql-codegen/plugin-helpers': 2.7.2(graphql@16.11.0) - '@graphql-codegen/visitor-plugin-common': 2.12.0(encoding@0.1.13)(graphql@16.11.0) + '@graphql-codegen/plugin-helpers': 2.7.2(graphql@16.12.0) + '@graphql-codegen/visitor-plugin-common': 2.12.0(encoding@0.1.13)(graphql@16.12.0) auto-bind: 4.0.0 change-case-all: 1.0.14 - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.4.1 transitivePeerDependencies: - encoding - supports-color - '@graphql-codegen/typescript@2.8.8(encoding@0.1.13)(graphql@16.11.0)': + '@graphql-codegen/typescript@2.8.8(encoding@0.1.13)(graphql@16.12.0)': dependencies: - '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.11.0) - '@graphql-codegen/schema-ast': 2.6.1(graphql@16.11.0) - '@graphql-codegen/visitor-plugin-common': 2.13.8(encoding@0.1.13)(graphql@16.11.0) + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.12.0) + '@graphql-codegen/schema-ast': 2.6.1(graphql@16.12.0) + '@graphql-codegen/visitor-plugin-common': 2.13.8(encoding@0.1.13)(graphql@16.12.0) auto-bind: 4.0.0 - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.4.1 transitivePeerDependencies: - encoding - supports-color - '@graphql-codegen/visitor-plugin-common@2.12.0(encoding@0.1.13)(graphql@16.11.0)': + '@graphql-codegen/visitor-plugin-common@2.12.0(encoding@0.1.13)(graphql@16.12.0)': dependencies: - '@graphql-codegen/plugin-helpers': 2.7.2(graphql@16.11.0) - '@graphql-tools/optimize': 1.4.0(graphql@16.11.0) - '@graphql-tools/relay-operation-optimizer': 6.5.18(encoding@0.1.13)(graphql@16.11.0) - '@graphql-tools/utils': 8.13.1(graphql@16.11.0) + '@graphql-codegen/plugin-helpers': 2.7.2(graphql@16.12.0) + '@graphql-tools/optimize': 1.4.0(graphql@16.12.0) + '@graphql-tools/relay-operation-optimizer': 6.5.18(encoding@0.1.13)(graphql@16.12.0) + '@graphql-tools/utils': 8.13.1(graphql@16.12.0) auto-bind: 4.0.0 change-case-all: 1.0.14 dependency-graph: 0.11.0 - graphql: 16.11.0 - graphql-tag: 2.12.6(graphql@16.11.0) + graphql: 16.12.0 + graphql-tag: 2.12.6(graphql@16.12.0) parse-filepath: 1.0.2 tslib: 2.4.1 transitivePeerDependencies: - encoding - supports-color - '@graphql-codegen/visitor-plugin-common@2.13.8(encoding@0.1.13)(graphql@16.11.0)': + '@graphql-codegen/visitor-plugin-common@2.13.8(encoding@0.1.13)(graphql@16.12.0)': dependencies: - '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.11.0) - '@graphql-tools/optimize': 1.4.0(graphql@16.11.0) - '@graphql-tools/relay-operation-optimizer': 6.5.18(encoding@0.1.13)(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.12.0) + '@graphql-tools/optimize': 1.4.0(graphql@16.12.0) + '@graphql-tools/relay-operation-optimizer': 6.5.18(encoding@0.1.13)(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) auto-bind: 4.0.0 change-case-all: 1.0.15 dependency-graph: 0.11.0 - graphql: 16.11.0 - graphql-tag: 2.12.6(graphql@16.11.0) + graphql: 16.12.0 + graphql-tag: 2.12.6(graphql@16.12.0) parse-filepath: 1.0.2 tslib: 2.4.1 transitivePeerDependencies: - encoding - supports-color - '@graphql-tools/apollo-engine-loader@7.3.26(encoding@0.1.13)(graphql@16.11.0)': + '@graphql-tools/apollo-engine-loader@7.3.26(encoding@0.1.13)(graphql@16.12.0)': dependencies: '@ardatan/sync-fetch': 0.0.1(encoding@0.1.13) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) '@whatwg-node/fetch': 0.8.8 - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - encoding - '@graphql-tools/batch-execute@8.5.22(graphql@16.11.0)': + '@graphql-tools/batch-execute@8.5.22(graphql@16.12.0)': dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) dataloader: 2.2.3 - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.8.1 value-or-promise: 1.0.12 - '@graphql-tools/code-file-loader@7.3.23(@babel/core@7.27.1)(graphql@16.11.0)': + '@graphql-tools/code-file-loader@7.3.23(@babel/core@7.27.1)(graphql@16.12.0)': dependencies: - '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.27.1)(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.27.1)(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) globby: 11.1.0 - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.8.1 unixify: 1.0.0 transitivePeerDependencies: - '@babel/core' - supports-color - '@graphql-tools/delegate@9.0.35(graphql@16.11.0)': + '@graphql-tools/delegate@9.0.35(graphql@16.12.0)': dependencies: - '@graphql-tools/batch-execute': 8.5.22(graphql@16.11.0) - '@graphql-tools/executor': 0.0.20(graphql@16.11.0) - '@graphql-tools/schema': 9.0.19(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/batch-execute': 8.5.22(graphql@16.12.0) + '@graphql-tools/executor': 0.0.20(graphql@16.12.0) + '@graphql-tools/schema': 9.0.19(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) dataloader: 2.2.3 - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.8.1 value-or-promise: 1.0.12 - '@graphql-tools/executor-graphql-ws@0.0.14(bufferutil@4.0.9)(graphql@16.11.0)(utf-8-validate@5.0.10)': + '@graphql-tools/executor-graphql-ws@0.0.14(bufferutil@4.0.9)(graphql@16.12.0)(utf-8-validate@5.0.10)': dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) '@repeaterjs/repeater': 3.0.4 '@types/ws': 8.18.1 - graphql: 16.11.0 - graphql-ws: 5.12.1(graphql@16.11.0) + graphql: 16.12.0 + graphql-ws: 5.12.1(graphql@16.12.0) isomorphic-ws: 5.0.0(ws@8.13.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) tslib: 2.8.1 ws: 8.13.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -11908,25 +12043,25 @@ snapshots: - bufferutil - utf-8-validate - '@graphql-tools/executor-http@0.1.10(@types/node@16.18.11)(graphql@16.11.0)': + '@graphql-tools/executor-http@0.1.10(@types/node@16.18.11)(graphql@16.12.0)': dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/fetch': 0.8.8 dset: 3.1.4 extract-files: 11.0.0 - graphql: 16.11.0 + graphql: 16.12.0 meros: 1.3.2(@types/node@16.18.11) tslib: 2.8.1 value-or-promise: 1.0.12 transitivePeerDependencies: - '@types/node' - '@graphql-tools/executor-legacy-ws@0.0.11(bufferutil@4.0.9)(graphql@16.11.0)(utf-8-validate@5.0.10)': + '@graphql-tools/executor-legacy-ws@0.0.11(bufferutil@4.0.9)(graphql@16.12.0)(utf-8-validate@5.0.10)': dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) '@types/ws': 8.18.1 - graphql: 16.11.0 + graphql: 16.12.0 isomorphic-ws: 5.0.0(ws@8.13.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) tslib: 2.8.1 ws: 8.13.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -11934,20 +12069,20 @@ snapshots: - bufferutil - utf-8-validate - '@graphql-tools/executor@0.0.20(graphql@16.11.0)': + '@graphql-tools/executor@0.0.20(graphql@16.12.0)': dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) '@repeaterjs/repeater': 3.0.6 - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.8.1 value-or-promise: 1.0.12 - '@graphql-tools/git-loader@7.3.0(@babel/core@7.27.1)(graphql@16.11.0)': + '@graphql-tools/git-loader@7.3.0(@babel/core@7.27.1)(graphql@16.12.0)': dependencies: - '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.27.1)(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - graphql: 16.11.0 + '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.27.1)(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + graphql: 16.12.0 is-glob: 4.0.3 micromatch: 4.0.8 tslib: 2.8.1 @@ -11956,14 +12091,14 @@ snapshots: - '@babel/core' - supports-color - '@graphql-tools/github-loader@7.3.28(@babel/core@7.27.1)(@types/node@16.18.11)(encoding@0.1.13)(graphql@16.11.0)': + '@graphql-tools/github-loader@7.3.28(@babel/core@7.27.1)(@types/node@16.18.11)(encoding@0.1.13)(graphql@16.12.0)': dependencies: '@ardatan/sync-fetch': 0.0.1(encoding@0.1.13) - '@graphql-tools/executor-http': 0.1.10(@types/node@16.18.11)(graphql@16.11.0) - '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.27.1)(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/executor-http': 0.1.10(@types/node@16.18.11)(graphql@16.12.0) + '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.27.1)(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) '@whatwg-node/fetch': 0.8.8 - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.8.1 value-or-promise: 1.0.12 transitivePeerDependencies: @@ -11972,74 +12107,74 @@ snapshots: - encoding - supports-color - '@graphql-tools/graphql-file-loader@7.5.17(graphql@16.11.0)': + '@graphql-tools/graphql-file-loader@7.5.17(graphql@16.12.0)': dependencies: - '@graphql-tools/import': 6.7.18(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/import': 6.7.18(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) globby: 11.1.0 - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.8.1 unixify: 1.0.0 - '@graphql-tools/graphql-tag-pluck@7.5.2(@babel/core@7.27.1)(graphql@16.11.0)': + '@graphql-tools/graphql-tag-pluck@7.5.2(@babel/core@7.27.1)(graphql@16.12.0)': dependencies: '@babel/parser': 7.27.2 '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.27.1) '@babel/traverse': 7.27.1 '@babel/types': 7.27.1 - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - graphql: 16.11.0 + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - '@babel/core' - supports-color - '@graphql-tools/import@6.7.18(graphql@16.11.0)': + '@graphql-tools/import@6.7.18(graphql@16.12.0)': dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - graphql: 16.11.0 + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + graphql: 16.12.0 resolve-from: 5.0.0 tslib: 2.8.1 - '@graphql-tools/json-file-loader@7.4.18(graphql@16.11.0)': + '@graphql-tools/json-file-loader@7.4.18(graphql@16.12.0)': dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) globby: 11.1.0 - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.8.1 unixify: 1.0.0 - '@graphql-tools/load@7.8.14(graphql@16.11.0)': + '@graphql-tools/load@7.8.14(graphql@16.12.0)': dependencies: - '@graphql-tools/schema': 9.0.19(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - graphql: 16.11.0 + '@graphql-tools/schema': 9.0.19(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + graphql: 16.12.0 p-limit: 3.1.0 tslib: 2.8.1 - '@graphql-tools/merge@8.4.2(graphql@16.11.0)': + '@graphql-tools/merge@8.4.2(graphql@16.12.0)': dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - graphql: 16.11.0 + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + graphql: 16.12.0 tslib: 2.8.1 - '@graphql-tools/optimize@1.4.0(graphql@16.11.0)': + '@graphql-tools/optimize@1.4.0(graphql@16.12.0)': dependencies: - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.8.1 - '@graphql-tools/prisma-loader@7.2.72(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.11.0)(utf-8-validate@5.0.10)': + '@graphql-tools/prisma-loader@7.2.72(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.12.0)(utf-8-validate@5.0.10)': dependencies: - '@graphql-tools/url-loader': 7.17.18(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.11.0)(utf-8-validate@5.0.10) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/url-loader': 7.17.18(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.12.0)(utf-8-validate@5.0.10) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) '@types/js-yaml': 4.0.9 '@types/json-stable-stringify': 1.2.0 '@whatwg-node/fetch': 0.8.8 chalk: 4.1.2 - debug: 4.4.0 + debug: 4.4.3 dotenv: 16.6.1 - graphql: 16.11.0 - graphql-request: 6.1.0(encoding@0.1.13)(graphql@16.11.0) + graphql: 16.12.0 + graphql-request: 6.1.0(encoding@0.1.13)(graphql@16.12.0) http-proxy-agent: 6.1.1 https-proxy-agent: 6.2.1 jose: 4.15.9 @@ -12056,36 +12191,36 @@ snapshots: - supports-color - utf-8-validate - '@graphql-tools/relay-operation-optimizer@6.5.18(encoding@0.1.13)(graphql@16.11.0)': + '@graphql-tools/relay-operation-optimizer@6.5.18(encoding@0.1.13)(graphql@16.12.0)': dependencies: - '@ardatan/relay-compiler': 12.0.0(encoding@0.1.13)(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - graphql: 16.11.0 + '@ardatan/relay-compiler': 12.0.0(encoding@0.1.13)(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - encoding - supports-color - '@graphql-tools/schema@9.0.19(graphql@16.11.0)': + '@graphql-tools/schema@9.0.19(graphql@16.12.0)': dependencies: - '@graphql-tools/merge': 8.4.2(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - graphql: 16.11.0 + '@graphql-tools/merge': 8.4.2(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + graphql: 16.12.0 tslib: 2.8.1 value-or-promise: 1.0.12 - '@graphql-tools/url-loader@7.17.18(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.11.0)(utf-8-validate@5.0.10)': + '@graphql-tools/url-loader@7.17.18(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.12.0)(utf-8-validate@5.0.10)': dependencies: '@ardatan/sync-fetch': 0.0.1(encoding@0.1.13) - '@graphql-tools/delegate': 9.0.35(graphql@16.11.0) - '@graphql-tools/executor-graphql-ws': 0.0.14(bufferutil@4.0.9)(graphql@16.11.0)(utf-8-validate@5.0.10) - '@graphql-tools/executor-http': 0.1.10(@types/node@16.18.11)(graphql@16.11.0) - '@graphql-tools/executor-legacy-ws': 0.0.11(bufferutil@4.0.9)(graphql@16.11.0)(utf-8-validate@5.0.10) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - '@graphql-tools/wrap': 9.4.2(graphql@16.11.0) + '@graphql-tools/delegate': 9.0.35(graphql@16.12.0) + '@graphql-tools/executor-graphql-ws': 0.0.14(bufferutil@4.0.9)(graphql@16.12.0)(utf-8-validate@5.0.10) + '@graphql-tools/executor-http': 0.1.10(@types/node@16.18.11)(graphql@16.12.0) + '@graphql-tools/executor-legacy-ws': 0.0.11(bufferutil@4.0.9)(graphql@16.12.0)(utf-8-validate@5.0.10) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + '@graphql-tools/wrap': 9.4.2(graphql@16.12.0) '@types/ws': 8.18.1 '@whatwg-node/fetch': 0.8.8 - graphql: 16.11.0 + graphql: 16.12.0 isomorphic-ws: 5.0.0(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) tslib: 2.8.1 value-or-promise: 1.0.12 @@ -12096,29 +12231,29 @@ snapshots: - encoding - utf-8-validate - '@graphql-tools/utils@8.13.1(graphql@16.11.0)': + '@graphql-tools/utils@8.13.1(graphql@16.12.0)': dependencies: - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.8.1 - '@graphql-tools/utils@9.2.1(graphql@16.11.0)': + '@graphql-tools/utils@9.2.1(graphql@16.12.0)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) - graphql: 16.11.0 + '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + graphql: 16.12.0 tslib: 2.8.1 - '@graphql-tools/wrap@9.4.2(graphql@16.11.0)': + '@graphql-tools/wrap@9.4.2(graphql@16.12.0)': dependencies: - '@graphql-tools/delegate': 9.0.35(graphql@16.11.0) - '@graphql-tools/schema': 9.0.19(graphql@16.11.0) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) - graphql: 16.11.0 + '@graphql-tools/delegate': 9.0.35(graphql@16.12.0) + '@graphql-tools/schema': 9.0.19(graphql@16.12.0) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) + graphql: 16.12.0 tslib: 2.8.1 value-or-promise: 1.0.12 - '@graphql-typed-document-node/core@3.2.0(graphql@16.11.0)': + '@graphql-typed-document-node/core@3.2.0(graphql@16.12.0)': dependencies: - graphql: 16.11.0 + graphql: 16.12.0 '@grpc/grpc-js@1.13.4': dependencies: @@ -12260,6 +12395,53 @@ snapshots: '@img/sharp-win32-x64@0.34.1': optional: true + '@inquirer/ansi@1.0.2': + optional: true + + '@inquirer/confirm@5.1.21(@types/node@18.19.87)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@18.19.87) + '@inquirer/type': 3.0.10(@types/node@18.19.87) + optionalDependencies: + '@types/node': 18.19.87 + optional: true + + '@inquirer/confirm@5.1.21(@types/node@22.15.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.15.3) + '@inquirer/type': 3.0.10(@types/node@22.15.3) + optionalDependencies: + '@types/node': 22.15.3 + optional: true + + '@inquirer/core@10.3.2(@types/node@18.19.87)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@18.19.87) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 18.19.87 + optional: true + + '@inquirer/core@10.3.2(@types/node@22.15.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.15.3) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.15.3 + optional: true + '@inquirer/external-editor@1.0.2(@types/node@16.18.11)': dependencies: chardet: 2.1.0 @@ -12267,6 +12449,19 @@ snapshots: optionalDependencies: '@types/node': 16.18.11 + '@inquirer/figures@1.0.15': + optional: true + + '@inquirer/type@3.0.10(@types/node@18.19.87)': + optionalDependencies: + '@types/node': 18.19.87 + optional: true + + '@inquirer/type@3.0.10(@types/node@22.15.3)': + optionalDependencies: + '@types/node': 22.15.3 + optional: true + '@ionic/cli-framework-output@2.2.8': dependencies: '@ionic/utils-terminal': 2.3.5 @@ -12730,6 +12925,16 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true + '@mswjs/interceptors@0.40.0': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + optional: true + '@mui/core-downloads-tracker@6.4.11': {} '@mui/icons-material@6.4.11(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@18.3.20)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.20)(react@18.3.1))(@types/react@18.3.20)(react@18.3.1))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.20)(react@18.3.1)': @@ -12882,6 +13087,18 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@open-draft/deferred-promise@2.2.0': + optional: true + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + optional: true + + '@open-draft/until@2.1.0': + optional: true + '@opentelemetry/api@1.9.0': {} '@opentelemetry/semantic-conventions@1.38.0': {} @@ -14873,6 +15090,9 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/statuses@2.0.6': + optional: true + '@types/trusted-types@2.0.7': {} '@types/uuid@9.0.8': {} @@ -14914,7 +15134,7 @@ snapshots: '@typescript-eslint/types': 8.31.1 '@typescript-eslint/typescript-estree': 8.31.1(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.31.1 - debug: 4.4.0 + debug: 4.4.3 eslint: 9.25.1(jiti@1.21.7) typescript: 5.8.3 transitivePeerDependencies: @@ -15182,11 +15402,29 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitest/coverage-v8@2.1.8(vitest@2.1.8(@edge-runtime/vm@3.2.0)(@types/node@18.19.87)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.44.1))': + '@vitest/coverage-v8@2.1.8(vitest@2.1.8(@edge-runtime/vm@3.2.0)(@types/node@18.19.87)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.7(@types/node@18.19.87)(typescript@5.8.3))(terser@5.44.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 - debug: 4.4.0 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.17 + magicast: 0.3.5 + std-env: 3.9.0 + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 2.1.8(@edge-runtime/vm@3.2.0)(@types/node@18.19.87)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.7(@types/node@18.19.87)(typescript@5.8.3))(terser@5.44.1) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@2.1.8(vitest@2.1.8(@edge-runtime/vm@3.2.0)(@types/node@22.15.3)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.7(@types/node@22.15.3)(typescript@5.8.3))(terser@5.44.1))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -15196,7 +15434,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 - vitest: 2.1.8(@edge-runtime/vm@3.2.0)(@types/node@18.19.87)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.44.1) + vitest: 2.1.8(@edge-runtime/vm@3.2.0)(@types/node@22.15.3)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.7(@types/node@22.15.3)(typescript@5.8.3))(terser@5.44.1) transitivePeerDependencies: - supports-color @@ -15214,14 +15452,24 @@ snapshots: chai: 5.2.0 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.8(vite@5.4.19(@types/node@18.19.87)(terser@5.44.1))': + '@vitest/mocker@2.1.8(msw@2.12.7(@types/node@18.19.87)(typescript@5.8.3))(vite@5.4.19(@types/node@18.19.87)(terser@5.44.1))': dependencies: '@vitest/spy': 2.1.8 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: + msw: 2.12.7(@types/node@18.19.87)(typescript@5.8.3) vite: 5.4.19(@types/node@18.19.87)(terser@5.44.1) + '@vitest/mocker@2.1.8(msw@2.12.7(@types/node@22.15.3)(typescript@5.8.3))(vite@5.4.19(@types/node@22.15.3)(terser@5.44.1))': + dependencies: + '@vitest/spy': 2.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + msw: 2.12.7(@types/node@22.15.3)(typescript@5.8.3) + vite: 5.4.19(@types/node@22.15.3)(terser@5.44.1) + '@vitest/pretty-format@2.0.5': dependencies: tinyrainbow: 1.2.0 @@ -15286,7 +15534,7 @@ snapshots: '@vue/compiler-core@3.5.13': dependencies: - '@babel/parser': 7.27.2 + '@babel/parser': 7.28.4 '@vue/shared': 3.5.13 entities: 4.5.0 estree-walker: 2.0.2 @@ -16701,6 +16949,9 @@ snapshots: cli-width@3.0.0: {} + cli-width@4.1.0: + optional: true + client-only@0.0.1: {} cliui@6.0.0: @@ -16824,6 +17075,9 @@ snapshots: cookie@0.6.0: {} + cookie@1.1.1: + optional: true + core-util-is@1.0.3: {} corser@2.0.1: {} @@ -17576,7 +17830,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.25.1(jiti@1.21.7)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.0 + debug: 4.4.3 eslint: 9.25.1(jiti@1.21.7) get-tsconfig: 4.10.0 is-bun-module: 2.0.0 @@ -17959,6 +18213,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.4.4(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -18263,16 +18521,16 @@ snapshots: graphemer@1.4.0: {} - graphql-config@4.5.0(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.11.0)(utf-8-validate@5.0.10): + graphql-config@4.5.0(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.12.0)(utf-8-validate@5.0.10): dependencies: - '@graphql-tools/graphql-file-loader': 7.5.17(graphql@16.11.0) - '@graphql-tools/json-file-loader': 7.4.18(graphql@16.11.0) - '@graphql-tools/load': 7.8.14(graphql@16.11.0) - '@graphql-tools/merge': 8.4.2(graphql@16.11.0) - '@graphql-tools/url-loader': 7.17.18(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.11.0)(utf-8-validate@5.0.10) - '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + '@graphql-tools/graphql-file-loader': 7.5.17(graphql@16.12.0) + '@graphql-tools/json-file-loader': 7.4.18(graphql@16.12.0) + '@graphql-tools/load': 7.8.14(graphql@16.12.0) + '@graphql-tools/merge': 8.4.2(graphql@16.12.0) + '@graphql-tools/url-loader': 7.17.18(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.12.0)(utf-8-validate@5.0.10) + '@graphql-tools/utils': 9.2.1(graphql@16.12.0) cosmiconfig: 8.0.0 - graphql: 16.11.0 + graphql: 16.12.0 jiti: 1.17.1 minimatch: 4.2.3 string-env-interpolation: 1.0.1 @@ -18283,34 +18541,34 @@ snapshots: - encoding - utf-8-validate - graphql-request@5.2.0(encoding@0.1.13)(graphql@16.11.0): + graphql-request@5.2.0(encoding@0.1.13)(graphql@16.12.0): dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) cross-fetch: 3.2.0(encoding@0.1.13) extract-files: 9.0.0 form-data: 3.0.3 - graphql: 16.11.0 + graphql: 16.12.0 transitivePeerDependencies: - encoding - graphql-request@6.1.0(encoding@0.1.13)(graphql@16.11.0): + graphql-request@6.1.0(encoding@0.1.13)(graphql@16.12.0): dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) cross-fetch: 3.2.0(encoding@0.1.13) - graphql: 16.11.0 + graphql: 16.12.0 transitivePeerDependencies: - encoding - graphql-tag@2.12.6(graphql@16.11.0): + graphql-tag@2.12.6(graphql@16.12.0): dependencies: - graphql: 16.11.0 + graphql: 16.12.0 tslib: 2.8.1 - graphql-ws@5.12.1(graphql@16.11.0): + graphql-ws@5.12.1(graphql@16.12.0): dependencies: - graphql: 16.11.0 + graphql: 16.12.0 - graphql@16.11.0: {} + graphql@16.12.0: {} grpc-web@1.5.0: {} @@ -18374,6 +18632,9 @@ snapshots: capital-case: 1.0.4 tslib: 2.8.1 + headers-polyfill@4.0.3: + optional: true + history@5.3.0: dependencies: '@babel/runtime': 7.27.1 @@ -18443,7 +18704,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -18493,7 +18754,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -18697,6 +18958,9 @@ snapshots: call-bind: 1.0.8 define-properties: 1.2.1 + is-node-process@1.2.0: + optional: true + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -18869,8 +19133,8 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: - '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.0 + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -19583,8 +19847,8 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.27.2 - '@babel/types': 7.27.1 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 source-map-js: 1.2.1 make-dir@3.1.0: @@ -19787,6 +20051,58 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.3 + msw@2.12.7(@types/node@18.19.87)(typescript@5.8.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@18.19.87) + '@mswjs/interceptors': 0.40.0 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.7.0 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.2 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@types/node' + optional: true + + msw@2.12.7(@types/node@22.15.3)(typescript@5.8.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@22.15.3) + '@mswjs/interceptors': 0.40.0 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.7.0 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.2 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@types/node' + optional: true + muggle-string@0.4.1: {} multiformats@9.9.0: {} @@ -19795,6 +20111,9 @@ snapshots: mute-stream@0.0.8: {} + mute-stream@2.0.0: + optional: true + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -20114,6 +20433,9 @@ snapshots: os-paths@4.4.0: {} + outvariant@1.4.3: + optional: true + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -20302,6 +20624,9 @@ snapshots: path-to-regexp@6.2.1: {} + path-to-regexp@6.3.0: + optional: true + path-type@4.0.0: {} pathe@1.1.2: {} @@ -20332,6 +20657,9 @@ snapshots: picomatch@4.0.2: {} + picomatch@4.0.3: + optional: true + pify@2.3.0: {} pino-abstract-transport@0.5.0: @@ -20893,6 +21221,9 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + rettime@0.7.0: + optional: true + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -21300,6 +21631,9 @@ snapshots: statuses@1.5.0: {} + statuses@2.0.2: + optional: true + std-env@3.9.0: {} stop-iteration-iterator@1.1.0: @@ -21347,6 +21681,9 @@ snapshots: streamsearch@1.1.0: {} + strict-event-emitter@0.5.1: + optional: true + strict-uri-encode@2.0.0: {} string-argv@0.3.2: {} @@ -21491,11 +21828,11 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.1.6(picomatch@4.0.2)(svelte@5.28.2)(typescript@5.8.3): + svelte-check@4.1.6(picomatch@4.0.3)(svelte@5.28.2)(typescript@5.8.3): dependencies: '@jridgewell/trace-mapping': 0.3.25 chokidar: 4.0.3 - fdir: 6.4.4(picomatch@4.0.2) + fdir: 6.4.4(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 svelte: 5.28.2 @@ -21542,6 +21879,9 @@ snapshots: symbol-tree@3.2.4: {} + tagged-tag@1.0.0: + optional: true + tailwind-merge@2.6.0: {} tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@16.18.11)(typescript@5.8.3))): @@ -21696,10 +22036,18 @@ snapshots: tldts-core@6.1.86: {} + tldts-core@7.0.19: + optional: true + tldts@6.1.86: dependencies: tldts-core: 6.1.86 + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + optional: true + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -21714,6 +22062,11 @@ snapshots: dependencies: tldts: 6.1.86 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + optional: true + tr46@0.0.3: {} tr46@1.0.1: @@ -21978,6 +22331,11 @@ snapshots: type-fest@4.40.1: {} + type-fest@5.4.2: + dependencies: + tagged-tag: 1.0.0 + optional: true + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -22119,6 +22477,9 @@ snapshots: optionalDependencies: idb-keyval: 6.2.1 + until-async@3.0.2: + optional: true + untildify@4.0.0: {} update-browserslist-db@1.1.3(browserslist@4.24.5): @@ -22337,6 +22698,24 @@ snapshots: - supports-color - terser + vite-node@2.1.8(@types/node@22.15.3)(terser@5.44.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.19(@types/node@22.15.3)(terser@5.44.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-plugin-dts@4.5.3(@types/node@18.19.87)(rollup@4.40.2)(typescript@5.8.3)(vite@6.3.4(@types/node@18.19.87)(jiti@1.21.7)(terser@5.44.1)(tsx@4.19.4)(yaml@2.7.1)): dependencies: '@microsoft/api-extractor': 7.52.6(@types/node@18.19.87) @@ -22402,6 +22781,16 @@ snapshots: fsevents: 2.3.3 terser: 5.44.1 + vite@5.4.19(@types/node@22.15.3)(terser@5.44.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.3 + rollup: 4.40.2 + optionalDependencies: + '@types/node': 22.15.3 + fsevents: 2.3.3 + terser: 5.44.1 + vite@6.3.4(@types/node@18.19.87)(jiti@1.21.7)(terser@5.44.1)(tsx@4.19.4)(yaml@2.7.1): dependencies: esbuild: 0.25.4 @@ -22438,17 +22827,17 @@ snapshots: optionalDependencies: vite: 6.3.4(@types/node@22.15.3)(jiti@1.21.7)(terser@5.44.1)(tsx@4.19.4)(yaml@2.7.1) - vitest@2.1.8(@edge-runtime/vm@3.2.0)(@types/node@18.19.87)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.44.1): + vitest@2.1.8(@edge-runtime/vm@3.2.0)(@types/node@18.19.87)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.7(@types/node@18.19.87)(typescript@5.8.3))(terser@5.44.1): dependencies: '@vitest/expect': 2.1.8 - '@vitest/mocker': 2.1.8(vite@5.4.19(@types/node@18.19.87)(terser@5.44.1)) + '@vitest/mocker': 2.1.8(msw@2.12.7(@types/node@18.19.87)(typescript@5.8.3))(vite@5.4.19(@types/node@18.19.87)(terser@5.44.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.8 '@vitest/snapshot': 2.1.8 '@vitest/spy': 2.1.8 '@vitest/utils': 2.1.8 chai: 5.2.0 - debug: 4.4.0 + debug: 4.4.3 expect-type: 1.2.1 magic-string: 0.30.17 pathe: 1.1.2 @@ -22475,6 +22864,43 @@ snapshots: - supports-color - terser + vitest@2.1.8(@edge-runtime/vm@3.2.0)(@types/node@22.15.3)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.7(@types/node@22.15.3)(typescript@5.8.3))(terser@5.44.1): + dependencies: + '@vitest/expect': 2.1.8 + '@vitest/mocker': 2.1.8(msw@2.12.7(@types/node@22.15.3)(typescript@5.8.3))(vite@5.4.19(@types/node@22.15.3)(terser@5.44.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.8 + '@vitest/snapshot': 2.1.8 + '@vitest/spy': 2.1.8 + '@vitest/utils': 2.1.8 + chai: 5.2.0 + debug: 4.4.3 + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 1.1.2 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.0.2 + tinyrainbow: 1.2.0 + vite: 5.4.19(@types/node@22.15.3)(terser@5.44.1) + vite-node: 2.1.8(@types/node@22.15.3)(terser@5.44.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@edge-runtime/vm': 3.2.0 + '@types/node': 22.15.3 + jsdom: 25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vm-browserify@1.1.2: {} vscode-uri@3.1.0: {} @@ -22777,6 +23203,9 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.3: + optional: true + zimmerframe@1.1.2: {} zod@3.22.4: {}