@cartridge/controller 0.13.4 → 0.13.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.turbo/turbo-build$colon$deps.log +41 -21
  2. package/.turbo/turbo-build.log +40 -20
  3. package/HEADLESS_MODE.md +113 -0
  4. package/dist/controller.d.ts +9 -2
  5. package/dist/errors.d.ts +10 -0
  6. package/dist/iframe/security.d.ts +10 -0
  7. package/dist/index-BdTFKueB.js +1072 -0
  8. package/dist/index-BdTFKueB.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +2273 -2524
  11. package/dist/index.js.map +1 -1
  12. package/dist/node/index.cjs +30 -5
  13. package/dist/node/index.cjs.map +1 -1
  14. package/dist/node/index.d.cts +11 -1
  15. package/dist/node/index.d.ts +11 -1
  16. package/dist/node/index.js +29 -7
  17. package/dist/node/index.js.map +1 -1
  18. package/dist/session/provider.d.ts +8 -2
  19. package/dist/session.js +141 -139
  20. package/dist/session.js.map +1 -1
  21. package/dist/stats.html +1 -1
  22. package/dist/types.d.ts +20 -1
  23. package/dist/utils.d.ts +5 -3
  24. package/package.json +4 -3
  25. package/src/__tests__/asWalletStandard.test.ts +87 -0
  26. package/src/__tests__/headlessConnectApproval.test.ts +97 -0
  27. package/src/__tests__/iframeSecurity.test.ts +84 -0
  28. package/src/__tests__/parseChainId.test.ts +1 -1
  29. package/src/__tests__/toWasmPolicies.test.ts +89 -40
  30. package/src/controller.ts +165 -13
  31. package/src/errors.ts +30 -0
  32. package/src/iframe/base.ts +14 -3
  33. package/src/iframe/keychain.ts +8 -5
  34. package/src/iframe/security.ts +48 -0
  35. package/src/index.ts +1 -0
  36. package/src/session/provider.ts +77 -48
  37. package/src/types.ts +30 -1
  38. package/src/utils.ts +21 -7
  39. package/dist/provider-DSqqvDee.js +0 -369
  40. package/dist/provider-DSqqvDee.js.map +0 -1
package/src/controller.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { AsyncMethodReturns } from "@cartridge/penpal";
2
2
 
3
3
  import { Policy } from "@cartridge/presets";
4
+ import { StarknetInjectedWallet } from "@starknet-io/get-starknet-wallet-standard";
5
+ import type { WalletWithStarknetFeatures } from "@starknet-io/get-starknet-wallet-standard/features";
4
6
  import {
5
7
  AddInvokeTransactionResult,
6
8
  AddStarknetChainParameters,
@@ -10,7 +12,7 @@ import { constants, shortString, WalletAccount } from "starknet";
10
12
  import { version } from "../package.json";
11
13
  import ControllerAccount from "./account";
12
14
  import { KEYCHAIN_URL } from "./constants";
13
- import { NotReadyToConnect } from "./errors";
15
+ import { HeadlessAuthenticationError, NotReadyToConnect } from "./errors";
14
16
  import { KeychainIFrame } from "./iframe";
15
17
  import BaseProvider from "./provider";
16
18
  import {
@@ -18,6 +20,7 @@ import {
18
20
  Chain,
19
21
  ConnectError,
20
22
  ConnectReply,
23
+ ConnectOptions,
21
24
  ControllerOptions,
22
25
  IFrames,
23
26
  Keychain,
@@ -226,8 +229,18 @@ export default class ControllerProvider extends BaseProvider {
226
229
  }
227
230
 
228
231
  async connect(
229
- signupOptions?: AuthOptions,
232
+ options?: AuthOptions | ConnectOptions,
230
233
  ): Promise<WalletAccount | undefined> {
234
+ const connectOptions = Array.isArray(options) ? undefined : options;
235
+ const headless =
236
+ connectOptions?.username && connectOptions?.signer
237
+ ? {
238
+ username: connectOptions.username,
239
+ signer: connectOptions.signer,
240
+ password: connectOptions.password,
241
+ }
242
+ : undefined;
243
+
231
244
  if (!this.iframes) {
232
245
  return;
233
246
  }
@@ -239,21 +252,73 @@ export default class ControllerProvider extends BaseProvider {
239
252
  // Ensure iframe is created if using lazy loading
240
253
  if (!this.iframes.keychain) {
241
254
  this.iframes.keychain = this.createKeychainIframe();
242
- // Wait for the keychain to be ready
243
- await this.waitForKeychain();
244
255
  }
245
256
 
257
+ // Always wait for the keychain connection to be established
258
+ await this.waitForKeychain();
259
+
246
260
  if (!this.keychain || !this.iframes.keychain) {
247
261
  console.error(new NotReadyToConnect().message);
248
262
  return;
249
263
  }
250
264
 
251
- this.iframes.keychain.open();
252
-
253
265
  try {
266
+ if (headless) {
267
+ // Headless auth should not open the UI until the keychain determines
268
+ // user interaction is required (e.g. session approval).
269
+ const response = await this.keychain.connect({
270
+ username: headless.username,
271
+ signer: headless.signer,
272
+ password: headless.password,
273
+ });
274
+
275
+ if (response.code !== ResponseCodes.SUCCESS) {
276
+ throw new HeadlessAuthenticationError(
277
+ "message" in response && response.message
278
+ ? response.message
279
+ : "Headless authentication failed",
280
+ );
281
+ }
282
+
283
+ // Keychain will call onSessionCreated (awaitable) during headless connect,
284
+ // which probes and updates this.account. Keep a fallback for older keychains.
285
+ if (this.account) {
286
+ return this.account;
287
+ }
288
+
289
+ const address =
290
+ "address" in response && response.address ? response.address : null;
291
+ if (!address) {
292
+ throw new HeadlessAuthenticationError(
293
+ "Headless authentication failed",
294
+ );
295
+ }
296
+
297
+ this.account = new ControllerAccount(
298
+ this,
299
+ this.rpcUrl(),
300
+ address,
301
+ this.keychain,
302
+ this.options,
303
+ this.iframes.keychain,
304
+ );
305
+ this.emitAccountsChanged([address]);
306
+ return this.account;
307
+ }
308
+
309
+ // Only open modal if NOT headless
310
+ this.iframes.keychain.open();
311
+
254
312
  // Use connect() parameter if provided, otherwise fall back to constructor options
255
- const effectiveOptions = signupOptions ?? this.options.signupOptions;
256
- let response = await this.keychain.connect(effectiveOptions);
313
+ const effectiveOptions = Array.isArray(options)
314
+ ? options
315
+ : (connectOptions?.signupOptions ?? this.options.signupOptions);
316
+
317
+ // Pass options to keychain
318
+ let response = await this.keychain.connect({
319
+ signupOptions: effectiveOptions,
320
+ });
321
+
257
322
  if (response.code !== ResponseCodes.SUCCESS) {
258
323
  throw new Error(response.message);
259
324
  }
@@ -270,9 +335,25 @@ export default class ControllerProvider extends BaseProvider {
270
335
 
271
336
  return this.account;
272
337
  } catch (e) {
338
+ if (headless) {
339
+ if (e instanceof HeadlessAuthenticationError) {
340
+ throw e;
341
+ }
342
+
343
+ const message =
344
+ e instanceof Error
345
+ ? e.message
346
+ : typeof e === "object" && e && "message" in e
347
+ ? String((e as any).message)
348
+ : "Headless authentication failed";
349
+ throw new HeadlessAuthenticationError(message);
350
+ }
273
351
  console.log(e);
274
352
  } finally {
275
- this.iframes.keychain.close();
353
+ // Only close modal if it was opened (not headless)
354
+ if (!headless) {
355
+ this.iframes.keychain.close();
356
+ }
276
357
  }
277
358
  }
278
359
 
@@ -306,12 +387,22 @@ export default class ControllerProvider extends BaseProvider {
306
387
  }
307
388
 
308
389
  async disconnect() {
390
+ this.account = undefined;
391
+ this.emitAccountsChanged([]);
392
+
393
+ try {
394
+ if (typeof localStorage !== "undefined") {
395
+ localStorage.removeItem("lastUsedConnector");
396
+ }
397
+ } catch {
398
+ // Ignore environments where localStorage is unavailable.
399
+ }
400
+
309
401
  if (!this.keychain) {
310
402
  console.error(new NotReadyToConnect().message);
311
403
  return;
312
404
  }
313
405
 
314
- this.account = undefined;
315
406
  return this.keychain.disconnect();
316
407
  }
317
408
 
@@ -401,6 +492,13 @@ export default class ControllerProvider extends BaseProvider {
401
492
  this.keychain.openSettings();
402
493
  }
403
494
 
495
+ async close() {
496
+ if (!this.iframes || !this.iframes.keychain) {
497
+ return;
498
+ }
499
+ this.iframes.keychain.close();
500
+ }
501
+
404
502
  revoke(origin: string, _policy: Policy[]) {
405
503
  if (!this.keychain) {
406
504
  console.error(new NotReadyToConnect().message);
@@ -514,6 +612,54 @@ export default class ControllerProvider extends BaseProvider {
514
612
  return await this.keychain.delegateAccount();
515
613
  }
516
614
 
615
+ /**
616
+ * Returns a wallet standard interface for the controller.
617
+ * This allows using the controller with libraries that expect the wallet standard interface.
618
+ */
619
+ asWalletStandard(): WalletWithStarknetFeatures {
620
+ if (typeof window !== "undefined") {
621
+ console.warn(
622
+ `Casting Controller to WalletWithStarknetFeatures is an experimental feature. ` +
623
+ `Please report any issues at https://github.com/cartridge-gg/controller/issues`,
624
+ );
625
+ }
626
+
627
+ const controller = this;
628
+ const inner = new StarknetInjectedWallet(controller);
629
+
630
+ // Override disconnect to also disconnect controller
631
+ const disconnect = {
632
+ "standard:disconnect": {
633
+ version: "1.0.0" as const,
634
+ disconnect: async () => {
635
+ await inner.features["standard:disconnect"].disconnect();
636
+ await controller.disconnect();
637
+ },
638
+ },
639
+ };
640
+
641
+ return {
642
+ get version() {
643
+ return inner.version;
644
+ },
645
+ get name() {
646
+ return inner.name;
647
+ },
648
+ get icon() {
649
+ return inner.icon;
650
+ },
651
+ get chains() {
652
+ return inner.chains;
653
+ },
654
+ get accounts() {
655
+ return inner.accounts;
656
+ },
657
+ get features() {
658
+ return { ...inner.features, ...disconnect };
659
+ },
660
+ };
661
+ }
662
+
517
663
  /**
518
664
  * Opens the keychain in standalone mode (first-party context) for authentication.
519
665
  * This establishes first-party storage, enabling seamless iframe access across all games.
@@ -622,7 +768,9 @@ export default class ControllerProvider extends BaseProvider {
622
768
  const iframe = new KeychainIFrame({
623
769
  ...this.options,
624
770
  rpcUrl: this.rpcUrl(),
625
- onClose: this.keychain?.reset,
771
+ onClose: () => {
772
+ this.keychain?.reset?.();
773
+ },
626
774
  onConnect: (keychain) => {
627
775
  this.keychain = keychain;
628
776
  },
@@ -633,8 +781,12 @@ export default class ControllerProvider extends BaseProvider {
633
781
  encryptedBlob: encryptedBlob ?? undefined,
634
782
  username: username,
635
783
  onSessionCreated: async () => {
636
- // Re-probe to establish connection now that storage access is granted and session created
637
- await this.probe();
784
+ const previousAddress = this.account?.address;
785
+ const account = await this.probe();
786
+
787
+ if (account?.address && account.address !== previousAddress) {
788
+ this.emitAccountsChanged([account.address]);
789
+ }
638
790
  },
639
791
  });
640
792
 
package/src/errors.ts CHANGED
@@ -5,3 +5,33 @@ export class NotReadyToConnect extends Error {
5
5
  Object.setPrototypeOf(this, NotReadyToConnect.prototype);
6
6
  }
7
7
  }
8
+
9
+ export class HeadlessAuthenticationError extends Error {
10
+ constructor(
11
+ message: string,
12
+ public cause?: Error,
13
+ ) {
14
+ super(message);
15
+ this.name = "HeadlessAuthenticationError";
16
+
17
+ Object.setPrototypeOf(this, HeadlessAuthenticationError.prototype);
18
+ }
19
+ }
20
+
21
+ export class InvalidCredentialsError extends HeadlessAuthenticationError {
22
+ constructor(credentialType: string) {
23
+ super(`Invalid credentials provided for type: ${credentialType}`);
24
+ this.name = "InvalidCredentialsError";
25
+
26
+ Object.setPrototypeOf(this, InvalidCredentialsError.prototype);
27
+ }
28
+ }
29
+
30
+ export class HeadlessModeNotSupportedError extends Error {
31
+ constructor(operation: string) {
32
+ super(`Operation "${operation}" is not supported in headless mode`);
33
+ this.name = "HeadlessModeNotSupportedError";
34
+
35
+ Object.setPrototypeOf(this, HeadlessModeNotSupportedError.prototype);
36
+ }
37
+ }
@@ -1,5 +1,6 @@
1
1
  import { AsyncMethodReturns, connectToChild } from "@cartridge/penpal";
2
2
  import { Modal } from "../types";
3
+ import { buildIframeAllowList, validateKeychainIframeUrl } from "./security";
3
4
 
4
5
  export type IFrameOptions<CallSender> = Omit<
5
6
  ConstructorParameters<typeof IFrame>[0],
@@ -34,6 +35,7 @@ export class IFrame<CallSender extends {}> implements Modal {
34
35
  }
35
36
 
36
37
  this.url = url;
38
+ validateKeychainIframeUrl(url);
37
39
 
38
40
  const docHead = document.head;
39
41
 
@@ -53,8 +55,8 @@ export class IFrame<CallSender extends {}> implements Modal {
53
55
  iframe.sandbox.add("allow-popups-to-escape-sandbox");
54
56
  iframe.sandbox.add("allow-scripts");
55
57
  iframe.sandbox.add("allow-same-origin");
56
- iframe.allow =
57
- "publickey-credentials-create *; publickey-credentials-get *; clipboard-write; local-network-access *; payment *";
58
+ iframe.allow = buildIframeAllowList(url);
59
+ iframe.referrerPolicy = "no-referrer";
58
60
  iframe.style.scrollbarWidth = "none";
59
61
  iframe.style.setProperty("-ms-overflow-style", "none");
60
62
  iframe.style.setProperty("-webkit-scrollbar", "none");
@@ -122,12 +124,21 @@ export class IFrame<CallSender extends {}> implements Modal {
122
124
 
123
125
  connectToChild<CallSender>({
124
126
  iframe: this.iframe,
127
+ childOrigin: url.origin,
125
128
  methods: {
129
+ open: (_origin: string) => () => this.open(),
126
130
  close: (_origin: string) => () => this.close(),
127
131
  reload: (_origin: string) => () => window.location.reload(),
128
132
  ...methods,
129
133
  },
130
- }).promise.then(onConnect);
134
+ })
135
+ .promise.then(onConnect)
136
+ .catch((error) => {
137
+ console.error("Failed to establish secure keychain iframe connection", {
138
+ error,
139
+ childOrigin: url.origin,
140
+ });
141
+ });
131
142
 
132
143
  this.resize();
133
144
  window.addEventListener("resize", () => this.resize());
@@ -104,6 +104,13 @@ export class KeychainIFrame extends IFrame<Keychain> {
104
104
  );
105
105
  } else if (preset) {
106
106
  _url.searchParams.set("preset", preset);
107
+ if (policies) {
108
+ console.warn(
109
+ "[Controller] Both `preset` and `policies` provided to ControllerProvider. " +
110
+ "Policies are ignored when preset is set. " +
111
+ "Use `shouldOverridePresetPolicies: true` to override.",
112
+ );
113
+ }
107
114
  }
108
115
 
109
116
  // Add encrypted blob to URL fragment (hash) if present
@@ -119,11 +126,7 @@ export class KeychainIFrame extends IFrame<Keychain> {
119
126
  methods: {
120
127
  ...walletBridge.getIFrameMethods(),
121
128
  // Expose callback for keychain to notify parent that session was created and storage access granted
122
- onSessionCreated: (_origin: string) => () => {
123
- if (onSessionCreated) {
124
- onSessionCreated();
125
- }
126
- },
129
+ onSessionCreated: (_origin: string) => () => onSessionCreated?.(),
127
130
  onStarterpackPlay: (_origin: string) => async () => {
128
131
  if (onStarterpackPlayHandler) {
129
132
  await onStarterpackPlayHandler();
@@ -0,0 +1,48 @@
1
+ const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
2
+
3
+ export function isLocalhostHostname(hostname: string): boolean {
4
+ const normalized = hostname.toLowerCase();
5
+ return (
6
+ LOCALHOST_HOSTNAMES.has(normalized) || normalized.endsWith(".localhost")
7
+ );
8
+ }
9
+
10
+ /**
11
+ * Restrict iframe targets to HTTPS in production, while still allowing local HTTP dev.
12
+ */
13
+ export function validateKeychainIframeUrl(url: URL): void {
14
+ if (url.username || url.password) {
15
+ throw new Error("Invalid keychain iframe URL: credentials are not allowed");
16
+ }
17
+
18
+ if (url.protocol === "https:") {
19
+ return;
20
+ }
21
+
22
+ if (url.protocol === "http:" && isLocalhostHostname(url.hostname)) {
23
+ return;
24
+ }
25
+
26
+ throw new Error(
27
+ "Invalid keychain iframe URL: only https:// or local http:// URLs are allowed",
28
+ );
29
+ }
30
+
31
+ /**
32
+ * Build a conservative allow list for iframe feature policy.
33
+ * Local network access is only needed for localhost development.
34
+ */
35
+ export function buildIframeAllowList(url: URL): string {
36
+ const allowFeatures = [
37
+ "publickey-credentials-create *",
38
+ "publickey-credentials-get *",
39
+ "clipboard-write",
40
+ "payment *",
41
+ ];
42
+
43
+ if (isLocalhostHostname(url.hostname)) {
44
+ allowFeatures.push("local-network-access *");
45
+ }
46
+
47
+ return allowFeatures.join("; ");
48
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./errors";
3
3
  export * from "./types";
4
4
  export * from "./lookup";
5
5
  export * from "./utils";
6
+ export * from "./policies";
6
7
  export * from "./wallets";
7
8
  export * from "./toast";
8
9
  export * from "@cartridge/presets";
@@ -4,14 +4,14 @@ import {
4
4
  signerToGuid,
5
5
  subscribeCreateSession,
6
6
  } from "@cartridge/controller-wasm";
7
- import { SessionPolicies } from "@cartridge/presets";
7
+ import { loadConfig, SessionPolicies } from "@cartridge/presets";
8
8
  import { AddStarknetChainParameters } from "@starknet-io/types-js";
9
9
  import { encode } from "starknet";
10
10
  import { API_URL, KEYCHAIN_URL } from "../constants";
11
- import { ParsedSessionPolicies } from "../policies";
11
+ import { parsePolicies, ParsedSessionPolicies } from "../policies";
12
12
  import BaseProvider from "../provider";
13
13
  import { AuthOptions } from "../types";
14
- import { toWasmPolicies } from "../utils";
14
+ import { getPresetSessionPolicies, toWasmPolicies } from "../utils";
15
15
  import SessionAccount from "./account";
16
16
 
17
17
  interface SessionRegistration {
@@ -23,12 +23,15 @@ interface SessionRegistration {
23
23
  guardianKeyGuid: string;
24
24
  metadataHash: string;
25
25
  sessionKeyGuid: string;
26
+ allowedPoliciesRoot?: string;
26
27
  }
27
28
 
28
29
  export type SessionOptions = {
29
30
  rpc: string;
30
31
  chainId: string;
31
- policies: SessionPolicies;
32
+ policies?: SessionPolicies;
33
+ preset?: string;
34
+ shouldOverridePresetPolicies?: boolean;
32
35
  redirectUrl: string;
33
36
  disconnectRedirectUrl?: string;
34
37
  keychainUrl?: string;
@@ -46,10 +49,12 @@ export default class SessionProvider extends BaseProvider {
46
49
  protected _redirectUrl: string;
47
50
  protected _disconnectRedirectUrl?: string;
48
51
  protected _policies: ParsedSessionPolicies;
52
+ protected _preset?: string;
53
+ private _ready: Promise<void>;
49
54
  protected _keychainUrl: string;
50
55
  protected _apiUrl: string;
51
- protected _publicKey: string;
52
- protected _sessionKeyGuid: string;
56
+ protected _publicKey!: string;
57
+ protected _sessionKeyGuid!: string;
53
58
  protected _signupOptions?: AuthOptions;
54
59
  public reopenBrowser: boolean = true;
55
60
 
@@ -57,6 +62,8 @@ export default class SessionProvider extends BaseProvider {
57
62
  rpc,
58
63
  chainId,
59
64
  policies,
65
+ preset,
66
+ shouldOverridePresetPolicies,
60
67
  redirectUrl,
61
68
  disconnectRedirectUrl,
62
69
  keychainUrl,
@@ -65,27 +72,27 @@ export default class SessionProvider extends BaseProvider {
65
72
  }: SessionOptions) {
66
73
  super();
67
74
 
68
- this._policies = {
69
- verified: false,
70
- contracts: policies.contracts
71
- ? Object.fromEntries(
72
- Object.entries(policies.contracts).map(([address, contract]) => [
73
- address,
74
- {
75
- ...contract,
76
- methods: contract.methods.map((method) => ({
77
- ...method,
78
- authorized: true,
79
- })),
80
- },
81
- ]),
82
- )
83
- : undefined,
84
- messages: policies.messages?.map((message) => ({
85
- ...message,
86
- authorized: true,
87
- })),
88
- };
75
+ if (!policies && !preset) {
76
+ throw new Error("Either `policies` or `preset` must be provided");
77
+ }
78
+
79
+ // Policy precedence logic (matching ControllerProvider):
80
+ // 1. If shouldOverridePresetPolicies is true and policies are provided, use policies
81
+ // 2. Otherwise, if preset is defined, resolve policies from preset
82
+ // 3. Otherwise, use provided policies
83
+ if ((!preset || shouldOverridePresetPolicies) && policies) {
84
+ this._policies = parsePolicies(policies);
85
+ } else {
86
+ this._preset = preset;
87
+ if (policies) {
88
+ console.warn(
89
+ "[Controller] Both `preset` and `policies` provided to SessionProvider. " +
90
+ "Policies are ignored when preset is set. " +
91
+ "Use `shouldOverridePresetPolicies: true` to override.",
92
+ );
93
+ }
94
+ this._policies = { verified: false };
95
+ }
89
96
 
90
97
  this._rpcUrl = rpc;
91
98
  this._chainId = chainId;
@@ -95,6 +102,33 @@ export default class SessionProvider extends BaseProvider {
95
102
  this._apiUrl = apiUrl ?? API_URL;
96
103
  this._signupOptions = signupOptions;
97
104
 
105
+ // Eagerly start async init: resolve preset policies (if any),
106
+ // then try to restore an existing session from storage.
107
+ // All public async methods await this before proceeding.
108
+ this._ready = this._init();
109
+
110
+ if (typeof window !== "undefined") {
111
+ (window as any).starknet_controller_session = this;
112
+ }
113
+ }
114
+
115
+ private async _init(): Promise<void> {
116
+ if (this._preset) {
117
+ const config = await loadConfig(this._preset);
118
+ if (!config) {
119
+ throw new Error(`Failed to load preset: ${this._preset}`);
120
+ }
121
+
122
+ const sessionPolicies = getPresetSessionPolicies(config, this._chainId);
123
+ if (!sessionPolicies) {
124
+ throw new Error(
125
+ `No policies found for chain ${this._chainId} in preset ${this._preset}`,
126
+ );
127
+ }
128
+
129
+ this._policies = parsePolicies(sessionPolicies);
130
+ }
131
+
98
132
  const account = this.tryRetrieveFromQueryOrStorage();
99
133
  if (!account) {
100
134
  const pk = stark.randomAddress();
@@ -124,10 +158,6 @@ export default class SessionProvider extends BaseProvider {
124
158
  starknet: { privateKey: encode.addHexPrefix(jsonPk.privKey) },
125
159
  });
126
160
  }
127
-
128
- if (typeof window !== "undefined") {
129
- (window as any).starknet_controller_session = this;
130
- }
131
161
  }
132
162
 
133
163
  private validatePoliciesSubset(
@@ -217,25 +247,18 @@ export default class SessionProvider extends BaseProvider {
217
247
  }
218
248
 
219
249
  async username() {
220
- await this.tryRetrieveFromQueryOrStorage();
250
+ await this._ready;
221
251
  return this._username;
222
252
  }
223
253
 
224
254
  async probe(): Promise<WalletAccount | undefined> {
225
- if (this.account) {
226
- return this.account;
227
- }
228
-
229
- this.account = this.tryRetrieveFromQueryOrStorage();
255
+ await this._ready;
230
256
  return this.account;
231
257
  }
232
258
 
233
259
  async connect(): Promise<WalletAccount | undefined> {
234
- if (this.account) {
235
- return this.account;
236
- }
260
+ await this._ready;
237
261
 
238
- this.account = this.tryRetrieveFromQueryOrStorage();
239
262
  if (this.account) {
240
263
  return this.account;
241
264
  }
@@ -258,13 +281,18 @@ export default class SessionProvider extends BaseProvider {
258
281
  this._sessionKeyGuid = signerToGuid({
259
282
  starknet: { privateKey: encode.addHexPrefix(pk) },
260
283
  });
261
- let url = `${
262
- this._keychainUrl
263
- }/session?public_key=${this._publicKey}&redirect_uri=${
264
- this._redirectUrl
265
- }&redirect_query_name=startapp&policies=${JSON.stringify(
266
- this._policies,
267
- )}&rpc_url=${this._rpcUrl}`;
284
+ let url =
285
+ `${this._keychainUrl}` +
286
+ `/session?public_key=${this._publicKey}` +
287
+ `&redirect_uri=${this._redirectUrl}` +
288
+ `&redirect_query_name=startapp` +
289
+ `&rpc_url=${this._rpcUrl}`;
290
+
291
+ if (this._preset) {
292
+ url += `&preset=${encodeURIComponent(this._preset)}`;
293
+ } else {
294
+ url += `&policies=${encodeURIComponent(JSON.stringify(this._policies))}`;
295
+ }
268
296
 
269
297
  if (this._signupOptions) {
270
298
  url += `&signers=${encodeURIComponent(JSON.stringify(this._signupOptions))}`;
@@ -314,6 +342,7 @@ export default class SessionProvider extends BaseProvider {
314
342
  localStorage.removeItem("sessionPolicies");
315
343
  localStorage.removeItem("lastUsedConnector");
316
344
  this.account = undefined;
345
+ this.emitAccountsChanged([]);
317
346
  this._username = undefined;
318
347
  const disconnectUrl = new URL(`${this._keychainUrl}`);
319
348
  disconnectUrl.pathname = "disconnect";
package/src/types.ts CHANGED
@@ -131,7 +131,7 @@ export type ControllerAccounts = Record<ContractAddress, CartridgeID>;
131
131
 
132
132
  export interface Keychain {
133
133
  probe(rpcUrl: string): Promise<ProbeReply | ConnectError>;
134
- connect(signupOptions?: AuthOptions): Promise<ConnectReply | ConnectError>;
134
+ connect(options?: ConnectOptions): Promise<ConnectReply | ConnectError>;
135
135
  disconnect(): void;
136
136
 
137
137
  reset(): void;
@@ -276,3 +276,32 @@ export type StarterpackOptions = {
276
276
  /** Callback fired after the Play button closes the starterpack modal */
277
277
  onPurchaseComplete?: () => void;
278
278
  };
279
+
280
+ // Connect options (used by controller.connect)
281
+ export interface ConnectOptions {
282
+ /** Signup options (shown in UI when not headless) */
283
+ signupOptions?: AuthOptions;
284
+ /** Headless mode username (when combined with signer) */
285
+ username?: string;
286
+ /** Headless mode signer option (auth method) */
287
+ signer?: AuthOption;
288
+ /** Required when signer is "password" */
289
+ password?: string;
290
+ }
291
+
292
+ export type HeadlessConnectOptions = Required<
293
+ Pick<ConnectOptions, "username" | "signer">
294
+ > &
295
+ Pick<ConnectOptions, "password">;
296
+
297
+ export type HeadlessConnectReply =
298
+ | {
299
+ code: ResponseCodes.SUCCESS;
300
+ address: string;
301
+ }
302
+ | {
303
+ code: ResponseCodes.USER_INTERACTION_REQUIRED;
304
+ requestId: string;
305
+ message?: string;
306
+ }
307
+ | ConnectError;