Skip to content

Commit 4426cf0

Browse files
authored
feat: add updateSession API for runtime session policy updates (#2440)
Adds the ability to update session policies after initial connection without requiring a full reconnect. ## Changes - **Controller SDK**: New `UpdateSessionOptions` type and `updateSession()` method on `ControllerProvider` that opens the keychain iframe, delegates to the keychain, and handles the response. - **Keychain interface**: New `updateSession` method on the `Keychain` interface accepting either direct `policies` or a `preset` name. - **Keychain connection layer**: New `update-session.ts` with URL construction, param parsing, and the Penpal-exposed factory function that navigates the iframe to `/update-session`. - **Keychain UI**: New `UpdateSessionRoute` component that resolves policies (directly or from a preset), auto-creates sessions for verified policies, and shows a consent screen for unverified ones.
1 parent 2dbe12c commit 4426cf0

8 files changed

Lines changed: 500 additions & 0 deletions

File tree

examples/next/src/app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Profile } from "components/Profile";
1313
import { SignMessage } from "components/SignMessage";
1414
import { Transfer } from "components/Transfer";
1515
import { Starterpack } from "components/Starterpack";
16+
import { UpdateSession } from "components/UpdateSession";
1617
import { ControllerToaster } from "@cartridge/ui";
1718

1819
const Home: FC = () => {
@@ -30,6 +31,7 @@ const Home: FC = () => {
3031
<Transfer />
3132
<ManualTransferEth />
3233
<Starterpack />
34+
<UpdateSession />
3335
<DelegateAccount />
3436
<InvalidTxn />
3537
<SignMessage />
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"use client";
2+
3+
import { useAccount } from "@starknet-react/core";
4+
import ControllerConnector from "@cartridge/connector/controller";
5+
import { Button } from "@cartridge/ui";
6+
import { useState } from "react";
7+
8+
export const UpdateSession = () => {
9+
const { account, connector } = useAccount();
10+
const [loading, setLoading] = useState(false);
11+
12+
const controllerConnector = connector as unknown as ControllerConnector;
13+
14+
if (!account) {
15+
return null;
16+
}
17+
18+
return (
19+
<div className="flex flex-col gap-2">
20+
<h2>Update Session</h2>
21+
<div className="flex items-center gap-2">
22+
<Button
23+
disabled={loading}
24+
onClick={async () => {
25+
setLoading(true);
26+
try {
27+
const response =
28+
await controllerConnector.controller.updateSession({
29+
preset: "loot-survivor",
30+
});
31+
console.log("Session updated:", response);
32+
} catch (e) {
33+
console.error("Failed to update session:", e);
34+
} finally {
35+
setLoading(false);
36+
}
37+
}}
38+
>
39+
{loading ? "Updating..." : "Update Session (loot-survivor)"}
40+
</Button>
41+
</div>
42+
</div>
43+
);
44+
};

packages/controller/src/controller.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
OpenOptions,
3232
HeadlessUsernameLookupResult,
3333
StarterpackOptions,
34+
UpdateSessionOptions,
3435
} from "./types";
3536
import { validateRedirectUrl } from "./url-validator";
3637
import { parseChainId } from "./utils";
@@ -509,6 +510,47 @@ export default class ControllerProvider extends BaseProvider {
509510
this.iframes.keychain.close();
510511
}
511512

513+
async updateSession(options: UpdateSessionOptions = {}) {
514+
if (!options.policies && !options.preset) {
515+
throw new Error("Either `policies` or `preset` must be provided");
516+
}
517+
518+
if (!this.iframes) {
519+
return;
520+
}
521+
522+
// Ensure iframe is created if using lazy loading
523+
if (!this.iframes.keychain) {
524+
this.iframes.keychain = this.createKeychainIframe();
525+
}
526+
527+
await this.waitForKeychain();
528+
529+
if (!this.keychain || !this.iframes.keychain) {
530+
console.error(new NotReadyToConnect().message);
531+
return;
532+
}
533+
534+
this.iframes.keychain.open();
535+
536+
try {
537+
const response = await this.keychain.updateSession(
538+
options.policies,
539+
options.preset,
540+
);
541+
542+
if (response.code !== ResponseCodes.SUCCESS) {
543+
throw new Error((response as ConnectError).message);
544+
}
545+
546+
return response as ConnectReply;
547+
} catch (e) {
548+
console.error(e);
549+
} finally {
550+
this.iframes.keychain.close();
551+
}
552+
}
553+
512554
revoke(origin: string, _policy: Policy[]) {
513555
if (!this.keychain) {
514556
console.error(new NotReadyToConnect().message);

packages/controller/src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ export interface Keychain {
157157
account: string,
158158
async?: boolean,
159159
): Promise<Signature | ConnectError>;
160+
updateSession(
161+
policies?: SessionPolicies,
162+
preset?: string,
163+
): Promise<ConnectReply | ConnectError>;
160164
openSettings(): Promise<void | ConnectError>;
161165
session(): Promise<KeychainSession>;
162166
sessions(): Promise<{
@@ -295,6 +299,14 @@ export interface ConnectOptions {
295299
password?: string;
296300
}
297301

302+
/** Options for updating session policies at runtime */
303+
export type UpdateSessionOptions = {
304+
/** Session policies to set */
305+
policies?: SessionPolicies;
306+
/** Preset name to resolve policies from */
307+
preset?: string;
308+
};
309+
298310
export type HeadlessConnectOptions = Required<
299311
Pick<ConnectOptions, "username" | "signer">
300312
> &
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { useCallback, useEffect, useState } from "react";
2+
import { ResponseCodes, getPresetSessionPolicies } from "@cartridge/controller";
3+
import { loadConfig } from "@cartridge/presets";
4+
import { useConnection } from "@/hooks/connection";
5+
import {
6+
parseSessionPolicies,
7+
type ParsedSessionPolicies,
8+
} from "@/hooks/session";
9+
import { cleanupCallbacks } from "@/utils/connection/callbacks";
10+
import { parseUpdateSessionParams } from "@/utils/connection/update-session";
11+
import { CreateSession } from "./connect/CreateSession";
12+
import {
13+
createVerifiedSession,
14+
requiresSessionApproval,
15+
} from "@/utils/connection/session-creation";
16+
import {
17+
useRouteParams,
18+
useRouteCompletion,
19+
useRouteCallbacks,
20+
} from "@/hooks/route";
21+
import { ControllerErrorAlert } from "@/components/ErrorAlert";
22+
import {
23+
Button,
24+
HeaderInner,
25+
LayoutContent,
26+
LayoutFooter,
27+
SpinnerIcon,
28+
} from "@cartridge/ui";
29+
30+
const CANCEL_RESPONSE = {
31+
code: ResponseCodes.CANCELED,
32+
message: "Canceled",
33+
};
34+
35+
export function UpdateSessionRoute() {
36+
const { controller, origin, theme, chainId, verified } = useConnection();
37+
const [resolvedPolicies, setResolvedPolicies] =
38+
useState<ParsedSessionPolicies>();
39+
const [isLoading, setIsLoading] = useState(false);
40+
const [isSessionCreating, setIsSessionCreating] = useState(false);
41+
const [sessionError, setSessionError] = useState<Error>();
42+
43+
const params = useRouteParams((searchParams: URLSearchParams) => {
44+
return parseUpdateSessionParams(searchParams);
45+
});
46+
47+
const handleCompletion = useRouteCompletion();
48+
49+
useRouteCallbacks(params, CANCEL_RESPONSE);
50+
51+
// Resolve policies from params (either direct policies or from preset)
52+
useEffect(() => {
53+
if (!params) return;
54+
55+
const { policies, preset } = params.params;
56+
57+
if (policies) {
58+
// Direct policies provided - parse them
59+
const parsed = parseSessionPolicies({
60+
policies,
61+
verified,
62+
});
63+
setResolvedPolicies(parsed);
64+
return;
65+
}
66+
67+
if (preset && chainId) {
68+
// Resolve policies from preset
69+
setIsLoading(true);
70+
loadConfig(preset)
71+
.then((config) => {
72+
if (!config) {
73+
console.error(`Failed to load preset: ${preset}`);
74+
return;
75+
}
76+
77+
const sessionPolicies = getPresetSessionPolicies(
78+
config as Record<string, unknown>,
79+
chainId,
80+
);
81+
if (!sessionPolicies) {
82+
console.error(
83+
`No policies found for chain ${chainId} in preset ${preset}`,
84+
);
85+
return;
86+
}
87+
88+
const parsed = parseSessionPolicies({
89+
policies: sessionPolicies,
90+
verified,
91+
});
92+
setResolvedPolicies(parsed);
93+
})
94+
.catch((error) => {
95+
console.error("Failed to resolve preset policies:", error);
96+
})
97+
.finally(() => {
98+
setIsLoading(false);
99+
});
100+
}
101+
}, [params, chainId, verified]);
102+
103+
const handleConnect = useCallback(async () => {
104+
if (!params || !controller) {
105+
return;
106+
}
107+
108+
params.resolve?.({
109+
code: ResponseCodes.SUCCESS,
110+
address: controller.address(),
111+
});
112+
if (params.params.id) {
113+
cleanupCallbacks(params.params.id);
114+
}
115+
handleCompletion();
116+
}, [params, controller, handleCompletion]);
117+
118+
// Auto-create session for verified policies that don't require approval
119+
useEffect(() => {
120+
if (!resolvedPolicies || !controller || !params) return;
121+
122+
if (!requiresSessionApproval(resolvedPolicies)) {
123+
const autoCreate = async () => {
124+
try {
125+
setIsSessionCreating(true);
126+
await createVerifiedSession({
127+
controller,
128+
origin,
129+
policies: resolvedPolicies,
130+
});
131+
params.resolve?.({
132+
code: ResponseCodes.SUCCESS,
133+
address: controller.address(),
134+
});
135+
if (params.params.id) {
136+
cleanupCallbacks(params.params.id);
137+
}
138+
handleCompletion();
139+
} catch (e) {
140+
console.error("Failed to auto-create session:", e);
141+
setSessionError(e instanceof Error ? e : new Error(String(e)));
142+
} finally {
143+
setIsSessionCreating(false);
144+
}
145+
};
146+
147+
void autoCreate();
148+
}
149+
}, [resolvedPolicies, controller, params, origin, handleCompletion]);
150+
151+
// Loading state
152+
if (!controller || isLoading) {
153+
return (
154+
<>
155+
<HeaderInner
156+
className="pb-0"
157+
title={theme ? theme.name : "Update Session"}
158+
/>
159+
<LayoutContent className="flex items-center justify-center">
160+
<SpinnerIcon className="animate-spin" />
161+
</LayoutContent>
162+
</>
163+
);
164+
}
165+
166+
if (!resolvedPolicies) {
167+
return null;
168+
}
169+
170+
// Verified policies auto-creating
171+
if (resolvedPolicies.verified && !requiresSessionApproval(resolvedPolicies)) {
172+
if (sessionError) {
173+
return (
174+
<>
175+
<HeaderInner
176+
className="pb-0"
177+
title={theme ? theme.name : "Update Session"}
178+
/>
179+
<LayoutContent />
180+
<LayoutFooter>
181+
<ControllerErrorAlert className="mb-3" error={sessionError} />
182+
<Button
183+
className="w-full"
184+
disabled={isSessionCreating}
185+
isLoading={isSessionCreating}
186+
onClick={async () => {
187+
if (!controller) return;
188+
setIsSessionCreating(true);
189+
setSessionError(undefined);
190+
try {
191+
await createVerifiedSession({
192+
controller,
193+
origin,
194+
policies: resolvedPolicies,
195+
});
196+
params?.resolve?.({
197+
code: ResponseCodes.SUCCESS,
198+
address: controller.address(),
199+
});
200+
if (params?.params.id) {
201+
cleanupCallbacks(params.params.id);
202+
}
203+
handleCompletion();
204+
} catch (e) {
205+
setSessionError(
206+
e instanceof Error ? e : new Error(String(e)),
207+
);
208+
} finally {
209+
setIsSessionCreating(false);
210+
}
211+
}}
212+
>
213+
retry
214+
</Button>
215+
</LayoutFooter>
216+
</>
217+
);
218+
}
219+
return null;
220+
}
221+
222+
// Show CreateSession for policies that require approval
223+
return (
224+
<CreateSession
225+
policies={resolvedPolicies}
226+
onConnect={handleConnect}
227+
isUpdate
228+
/>
229+
);
230+
}

0 commit comments

Comments
 (0)