From ab36b841c704090a2a6fc7fbb0626ddc3d4bfb6c Mon Sep 17 00:00:00 2001 From: broody Date: Tue, 24 Feb 2026 13:42:34 -1000 Subject: [PATCH 1/3] feat: add updateSession API for runtime session policy updates Add the ability to update session policies after initial connection without requiring a full reconnect. - Add UpdateSessionOptions type and updateSession method to controller - Add updateSession to Keychain interface - Add keychain connection handler and URL routing utilities - Add UpdateSessionRoute component with auto-create for verified policies --- examples/next/src/app/page.tsx | 2 + .../next/src/components/UpdateSession.tsx | 44 ++++ packages/controller/src/controller.ts | 42 ++++ packages/controller/src/types.ts | 12 + .../src/components/UpdateSessionRoute.tsx | 230 ++++++++++++++++++ packages/keychain/src/components/app.tsx | 12 + .../keychain/src/utils/connection/index.ts | 2 + .../src/utils/connection/update-session.ts | 156 ++++++++++++ 8 files changed, 500 insertions(+) create mode 100644 examples/next/src/components/UpdateSession.tsx create mode 100644 packages/keychain/src/components/UpdateSessionRoute.tsx create mode 100644 packages/keychain/src/utils/connection/update-session.ts diff --git a/examples/next/src/app/page.tsx b/examples/next/src/app/page.tsx index 6a14125cd..7efd511f8 100644 --- a/examples/next/src/app/page.tsx +++ b/examples/next/src/app/page.tsx @@ -13,6 +13,7 @@ import { Profile } from "components/Profile"; import { SignMessage } from "components/SignMessage"; import { Transfer } from "components/Transfer"; import { Starterpack } from "components/Starterpack"; +import { UpdateSession } from "components/UpdateSession"; import { ControllerToaster } from "@cartridge/ui"; const Home: FC = () => { @@ -30,6 +31,7 @@ const Home: FC = () => { + diff --git a/examples/next/src/components/UpdateSession.tsx b/examples/next/src/components/UpdateSession.tsx new file mode 100644 index 000000000..859cb7813 --- /dev/null +++ b/examples/next/src/components/UpdateSession.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useAccount } from "@starknet-react/core"; +import ControllerConnector from "@cartridge/connector/controller"; +import { Button } from "@cartridge/ui"; +import { useState } from "react"; + +export const UpdateSession = () => { + const { account, connector } = useAccount(); + const [loading, setLoading] = useState(false); + + const controllerConnector = connector as unknown as ControllerConnector; + + if (!account) { + return null; + } + + return ( +
+

Update Session

+
+ +
+
+ ); +}; diff --git a/packages/controller/src/controller.ts b/packages/controller/src/controller.ts index 05c63871b..09abe70b1 100644 --- a/packages/controller/src/controller.ts +++ b/packages/controller/src/controller.ts @@ -31,6 +31,7 @@ import { OpenOptions, HeadlessUsernameLookupResult, StarterpackOptions, + UpdateSessionOptions, } from "./types"; import { validateRedirectUrl } from "./url-validator"; import { parseChainId } from "./utils"; @@ -508,6 +509,47 @@ export default class ControllerProvider extends BaseProvider { this.iframes.keychain.close(); } + async updateSession(options: UpdateSessionOptions = {}) { + if (!options.policies && !options.preset) { + throw new Error("Either `policies` or `preset` must be provided"); + } + + if (!this.iframes) { + return; + } + + // Ensure iframe is created if using lazy loading + if (!this.iframes.keychain) { + this.iframes.keychain = this.createKeychainIframe(); + } + + await this.waitForKeychain(); + + if (!this.keychain || !this.iframes.keychain) { + console.error(new NotReadyToConnect().message); + return; + } + + this.iframes.keychain.open(); + + try { + const response = await this.keychain.updateSession( + options.policies, + options.preset, + ); + + if (response.code !== ResponseCodes.SUCCESS) { + throw new Error((response as ConnectError).message); + } + + return response as ConnectReply; + } catch (e) { + console.error(e); + } finally { + this.iframes.keychain.close(); + } + } + revoke(origin: string, _policy: Policy[]) { if (!this.keychain) { console.error(new NotReadyToConnect().message); diff --git a/packages/controller/src/types.ts b/packages/controller/src/types.ts index 1be4fa556..c7d982aee 100644 --- a/packages/controller/src/types.ts +++ b/packages/controller/src/types.ts @@ -157,6 +157,10 @@ export interface Keychain { account: string, async?: boolean, ): Promise; + updateSession( + policies?: SessionPolicies, + preset?: string, + ): Promise; openSettings(): Promise; session(): Promise; sessions(): Promise<{ @@ -295,6 +299,14 @@ export interface ConnectOptions { password?: string; } +/** Options for updating session policies at runtime */ +export type UpdateSessionOptions = { + /** Session policies to set */ + policies?: SessionPolicies; + /** Preset name to resolve policies from */ + preset?: string; +}; + export type HeadlessConnectOptions = Required< Pick > & diff --git a/packages/keychain/src/components/UpdateSessionRoute.tsx b/packages/keychain/src/components/UpdateSessionRoute.tsx new file mode 100644 index 000000000..a51d10a28 --- /dev/null +++ b/packages/keychain/src/components/UpdateSessionRoute.tsx @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useState } from "react"; +import { ResponseCodes, getPresetSessionPolicies } from "@cartridge/controller"; +import { loadConfig } from "@cartridge/presets"; +import { useConnection } from "@/hooks/connection"; +import { + parseSessionPolicies, + type ParsedSessionPolicies, +} from "@/hooks/session"; +import { cleanupCallbacks } from "@/utils/connection/callbacks"; +import { parseUpdateSessionParams } from "@/utils/connection/update-session"; +import { CreateSession } from "./connect/CreateSession"; +import { + createVerifiedSession, + requiresSessionApproval, +} from "@/utils/connection/session-creation"; +import { + useRouteParams, + useRouteCompletion, + useRouteCallbacks, +} from "@/hooks/route"; +import { ControllerErrorAlert } from "@/components/ErrorAlert"; +import { + Button, + HeaderInner, + LayoutContent, + LayoutFooter, + SpinnerIcon, +} from "@cartridge/ui"; + +const CANCEL_RESPONSE = { + code: ResponseCodes.CANCELED, + message: "Canceled", +}; + +export function UpdateSessionRoute() { + const { controller, origin, theme, chainId, verified } = useConnection(); + const [resolvedPolicies, setResolvedPolicies] = + useState(); + const [isLoading, setIsLoading] = useState(false); + const [isSessionCreating, setIsSessionCreating] = useState(false); + const [sessionError, setSessionError] = useState(); + + const params = useRouteParams((searchParams: URLSearchParams) => { + return parseUpdateSessionParams(searchParams); + }); + + const handleCompletion = useRouteCompletion(); + + useRouteCallbacks(params, CANCEL_RESPONSE); + + // Resolve policies from params (either direct policies or from preset) + useEffect(() => { + if (!params) return; + + const { policies, preset } = params.params; + + if (policies) { + // Direct policies provided - parse them + const parsed = parseSessionPolicies({ + policies, + verified, + }); + setResolvedPolicies(parsed); + return; + } + + if (preset && chainId) { + // Resolve policies from preset + setIsLoading(true); + loadConfig(preset) + .then((config) => { + if (!config) { + console.error(`Failed to load preset: ${preset}`); + return; + } + + const sessionPolicies = getPresetSessionPolicies( + config as Record, + chainId, + ); + if (!sessionPolicies) { + console.error( + `No policies found for chain ${chainId} in preset ${preset}`, + ); + return; + } + + const parsed = parseSessionPolicies({ + policies: sessionPolicies, + verified, + }); + setResolvedPolicies(parsed); + }) + .catch((error) => { + console.error("Failed to resolve preset policies:", error); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, [params, chainId, verified]); + + const handleConnect = useCallback(async () => { + if (!params || !controller) { + return; + } + + params.resolve?.({ + code: ResponseCodes.SUCCESS, + address: controller.address(), + }); + if (params.params.id) { + cleanupCallbacks(params.params.id); + } + handleCompletion(); + }, [params, controller, handleCompletion]); + + // Auto-create session for verified policies that don't require approval + useEffect(() => { + if (!resolvedPolicies || !controller || !params) return; + + if (!requiresSessionApproval(resolvedPolicies)) { + const autoCreate = async () => { + try { + setIsSessionCreating(true); + await createVerifiedSession({ + controller, + origin, + policies: resolvedPolicies, + }); + params.resolve?.({ + code: ResponseCodes.SUCCESS, + address: controller.address(), + }); + if (params.params.id) { + cleanupCallbacks(params.params.id); + } + handleCompletion(); + } catch (e) { + console.error("Failed to auto-create session:", e); + setSessionError(e instanceof Error ? e : new Error(String(e))); + } finally { + setIsSessionCreating(false); + } + }; + + void autoCreate(); + } + }, [resolvedPolicies, controller, params, origin, handleCompletion]); + + // Loading state + if (!controller || isLoading) { + return ( + <> + + + + + + ); + } + + if (!resolvedPolicies) { + return null; + } + + // Verified policies auto-creating + if (resolvedPolicies.verified && !requiresSessionApproval(resolvedPolicies)) { + if (sessionError) { + return ( + <> + + + + + + + + ); + } + return null; + } + + // Show CreateSession for policies that require approval + return ( + + ); +} diff --git a/packages/keychain/src/components/app.tsx b/packages/keychain/src/components/app.tsx index fe6581635..3b71fcaee 100644 --- a/packages/keychain/src/components/app.tsx +++ b/packages/keychain/src/components/app.tsx @@ -40,6 +40,7 @@ import { CollectiblePurchase } from "./inventory/collection/collectible-purchase import { Execute } from "./Execute"; import { SignMessage } from "./SignMessage"; import { ConnectRoute } from "./ConnectRoute"; +import { UpdateSessionRoute } from "./UpdateSessionRoute"; import { Funding } from "./funding"; import { Deposit } from "./funding/Deposit"; import { useNavigation } from "@/context"; @@ -153,6 +154,16 @@ function Authentication() { return ; } + // Update-session should never flash login UI while controller state settles. + if (pathname.startsWith("/update-session") && !controller) { + return ( + + ); + } + // No controller, show CreateController if (!controller) { // Extract signers from URL if present (for connect flow) @@ -310,6 +321,7 @@ export function App() { } /> } /> } /> + } /> } diff --git a/packages/keychain/src/utils/connection/index.ts b/packages/keychain/src/utils/connection/index.ts index 6154638e4..0f452f973 100644 --- a/packages/keychain/src/utils/connection/index.ts +++ b/packages/keychain/src/utils/connection/index.ts @@ -15,6 +15,7 @@ import { openSettingsFactory } from "./settings"; import { signMessageFactory } from "./sign"; import { switchChain } from "./switchChain"; import { navigateFactory } from "./navigate"; +import { updateSession } from "./update-session"; import type { AuthOptions, ConnectOptions, @@ -123,6 +124,7 @@ export function connectToController< ); }, switchChain: () => switchChain({ setController, setRpcUrl }), + updateSession: updateSession({ navigate }), }, }); } diff --git a/packages/keychain/src/utils/connection/update-session.ts b/packages/keychain/src/utils/connection/update-session.ts new file mode 100644 index 000000000..ac2daf376 --- /dev/null +++ b/packages/keychain/src/utils/connection/update-session.ts @@ -0,0 +1,156 @@ +import type { + ConnectError, + ConnectReply, + SessionPolicies, +} from "@cartridge/controller"; +import { generateCallbackId, storeCallbacks, getCallbacks } from "./callbacks"; + +type UpdateSessionCallback = { + resolve?: (result: ConnectReply | ConnectError) => void; + reject?: (reason?: unknown) => void; + onCancel?: () => void; +}; + +function isUpdateSessionResult( + value: unknown, +): value is ConnectReply | ConnectError { + if (!value || typeof value !== "object") { + return false; + } + const obj = value as Record; + return typeof obj.code === "string" && ("address" in obj || "message" in obj); +} + +export function createUpdateSessionUrl( + policies?: SessionPolicies, + preset?: string, + options: UpdateSessionCallback = {}, +): string { + const id = generateCallbackId(); + + if (options.resolve || options.reject || options.onCancel) { + storeCallbacks(id, { + resolve: options.resolve + ? (result) => { + options.resolve?.(result as ConnectReply | ConnectError); + } + : undefined, + reject: options.reject, + onCancel: options.onCancel, + }); + } + + const params = new URLSearchParams({ id }); + + if (policies) { + params.set("policies", encodeURIComponent(JSON.stringify(policies))); + } + + if (preset) { + params.set("preset", encodeURIComponent(preset)); + } + + return `/update-session?${params.toString()}`; +} + +export function parseUpdateSessionParams(searchParams: URLSearchParams): { + params: { + id?: string; + policies?: SessionPolicies; + preset?: string; + }; + resolve?: (result: unknown) => void; + reject?: (reason?: unknown) => void; + onCancel?: () => void; +} | null { + try { + const id = searchParams.get("id"); + const policiesParam = searchParams.get("policies"); + const presetParam = searchParams.get("preset"); + + let policies: SessionPolicies | undefined; + if (policiesParam) { + try { + policies = JSON.parse( + decodeURIComponent(policiesParam), + ) as SessionPolicies; + } catch (e) { + console.error("Failed to parse update session policies:", e); + } + } + + const preset = presetParam ? decodeURIComponent(presetParam) : undefined; + + let callbacks: UpdateSessionCallback | undefined; + if (id) { + callbacks = getCallbacks(id) as UpdateSessionCallback | undefined; + } + + const reject = callbacks?.reject + ? (reason?: unknown) => { + callbacks.reject?.(reason); + } + : undefined; + + const resolve = callbacks?.resolve + ? (value: unknown) => { + if (!isUpdateSessionResult(value)) { + const error = new Error("Invalid update session result type"); + console.error(error.message, value); + reject?.(error); + return; + } + callbacks.resolve?.(value); + } + : undefined; + + const onCancel = callbacks?.onCancel + ? () => { + callbacks.onCancel?.(); + } + : undefined; + + return { + params: { id: id || undefined, policies, preset }, + resolve, + reject, + onCancel, + }; + } catch (error) { + console.error("Failed to parse update session params:", error); + return null; + } +} + +export function updateSession({ + navigate, +}: { + navigate: ( + to: string | number, + options?: { replace?: boolean; state?: unknown }, + ) => void; +}) { + return () => + (policies?: SessionPolicies, preset?: string): Promise => { + if (!policies && !preset) { + return Promise.reject( + new Error("Either `policies` or `preset` must be provided"), + ); + } + + return new Promise((resolve, reject) => { + const url = createUpdateSessionUrl(policies, preset, { + resolve: (result) => { + if ("address" in result) { + resolve(result); + } else { + reject(result); + } + }, + reject, + }); + + navigate(url, { replace: true }); + }); + }; +} From 23db1bb633cd70ae4967d0147bcb50a46c6e5589 Mon Sep 17 00:00:00 2001 From: broody Date: Tue, 24 Feb 2026 14:49:27 -1000 Subject: [PATCH 2/3] fix: prefer close over back in iframe header Ensure popup/iframe flows default to showing a close action instead of back navigation, while still allowing explicit force-back behavior. --- .../keychain/src/components/NavigationHeader.tsx | 6 ++++-- packages/keychain/src/components/app.tsx | 12 ++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/keychain/src/components/NavigationHeader.tsx b/packages/keychain/src/components/NavigationHeader.tsx index edff50a3d..c26a9d855 100644 --- a/packages/keychain/src/components/NavigationHeader.tsx +++ b/packages/keychain/src/components/NavigationHeader.tsx @@ -24,9 +24,11 @@ export function NavigationHeader({ // Check if we're in an iframe const isInIframe = window.self !== window.top; - // Determine which button to show based on navigation state + // Determine which button to show based on navigation state. + // In iframe/popup context, prefer close over back by default. const shouldShowBack = - forceShowBack || (canGoBack && !forceShowClose && !showClose); + forceShowBack || + (!isInIframe && canGoBack && !forceShowClose && !showClose); const shouldShowClose = isInIframe && (forceShowClose || showClose || !shouldShowBack); diff --git a/packages/keychain/src/components/app.tsx b/packages/keychain/src/components/app.tsx index 3b71fcaee..338375558 100644 --- a/packages/keychain/src/components/app.tsx +++ b/packages/keychain/src/components/app.tsx @@ -154,13 +154,13 @@ function Authentication() { return ; } - // Update-session should never flash login UI while controller state settles. - if (pathname.startsWith("/update-session") && !controller) { + // Update-session should bypass auth/login gating entirely so it never flashes + // CreateController (login) while controller state settles. + if (pathname.startsWith("/update-session")) { return ( - + + + ); } From 58b97503246718197542570fa3d19e784846d3d1 Mon Sep 17 00:00:00 2001 From: broody Date: Tue, 24 Feb 2026 15:01:08 -1000 Subject: [PATCH 3/3] chore: move iframe close header behavior to dedicated PR --- packages/keychain/src/components/NavigationHeader.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/keychain/src/components/NavigationHeader.tsx b/packages/keychain/src/components/NavigationHeader.tsx index c26a9d855..edff50a3d 100644 --- a/packages/keychain/src/components/NavigationHeader.tsx +++ b/packages/keychain/src/components/NavigationHeader.tsx @@ -24,11 +24,9 @@ export function NavigationHeader({ // Check if we're in an iframe const isInIframe = window.self !== window.top; - // Determine which button to show based on navigation state. - // In iframe/popup context, prefer close over back by default. + // Determine which button to show based on navigation state const shouldShowBack = - forceShowBack || - (!isInIframe && canGoBack && !forceShowClose && !showClose); + forceShowBack || (canGoBack && !forceShowClose && !showClose); const shouldShowClose = isInIframe && (forceShowClose || showClose || !shouldShowBack);