@cartridge/controller 0.13.7 → 0.13.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartridge/controller",
3
- "version": "0.13.7",
3
+ "version": "0.13.9",
4
4
  "description": "Cartridge Controller",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@cartridge/controller-wasm": "0.9.3",
29
+ "@cartridge/controller-wasm": "0.9.4",
30
30
  "@cartridge/penpal": "^6.2.4",
31
31
  "micro-sol-signer": "^0.5.0",
32
32
  "bs58": "^6.0.0",
@@ -56,14 +56,13 @@
56
56
  "vite-plugin-node-polyfills": "^0.23.0",
57
57
  "vite-plugin-top-level-await": "^1.4.4",
58
58
  "vite-plugin-wasm": "^3.4.1",
59
- "@cartridge/tsconfig": "0.13.7"
59
+ "@cartridge/tsconfig": "0.13.9"
60
60
  },
61
61
  "scripts": {
62
- "build:deps": "pnpm build",
62
+ "build:deps": "pnpm build:browser && pnpm build:node",
63
63
  "dev": "vite build --watch",
64
64
  "build:browser": "vite build",
65
65
  "build:node": "tsup --config tsup.node.config.ts",
66
- "build": "pnpm build:browser && pnpm build:node",
67
66
  "format": "prettier --write \"src/**/*.ts\"",
68
67
  "format:check": "prettier --check \"src/**/*.ts\"",
69
68
  "test": "jest",
@@ -0,0 +1,112 @@
1
+ import ControllerProvider from "../controller";
2
+
3
+ type MockStorage = {
4
+ [key: string]: string;
5
+ };
6
+
7
+ const createMockLocalStorage = () => {
8
+ const store: MockStorage = {};
9
+
10
+ return {
11
+ get length() {
12
+ return Object.keys(store).length;
13
+ },
14
+ clear: jest.fn(() => {
15
+ Object.keys(store).forEach((key) => {
16
+ delete store[key];
17
+ });
18
+ }),
19
+ getItem: jest.fn((key: string) => (key in store ? store[key] : null)),
20
+ setItem: jest.fn((key: string, value: string) => {
21
+ store[key] = String(value);
22
+ }),
23
+ removeItem: jest.fn((key: string) => {
24
+ delete store[key];
25
+ }),
26
+ key: jest.fn((index: number) => {
27
+ const keys = Object.keys(store);
28
+ return keys[index] || null;
29
+ }),
30
+ } as Storage;
31
+ };
32
+
33
+ describe("ControllerProvider.disconnect", () => {
34
+ const originalLocalStorage = (global as any).localStorage;
35
+
36
+ beforeEach(() => {
37
+ (global as any).localStorage = createMockLocalStorage();
38
+ });
39
+
40
+ afterEach(() => {
41
+ (global as any).localStorage = originalLocalStorage;
42
+ jest.restoreAllMocks();
43
+ });
44
+
45
+ test("cleans persisted connector/session localStorage keys", async () => {
46
+ const controller = new ControllerProvider({});
47
+ const keychainDisconnect = jest.fn().mockResolvedValue(undefined);
48
+ (controller as any).keychain = {
49
+ disconnect: keychainDisconnect,
50
+ };
51
+
52
+ localStorage.setItem("lastUsedConnector", "controller");
53
+ localStorage.setItem(
54
+ "@cartridge/account/0x40bda2fcd37963c0b8f951801c63a88132feb399dab0f5318245b2c59a553af/0x534e5f5345504f4c4941",
55
+ JSON.stringify({ Controller: {} }),
56
+ );
57
+ localStorage.setItem("@cartridge/active", JSON.stringify({ Active: {} }));
58
+ localStorage.setItem(
59
+ "@cartridge/https://x.cartridge.gg/active",
60
+ JSON.stringify({ Active: {} }),
61
+ );
62
+ localStorage.setItem(
63
+ "@cartridge/policies/0x4fdcb829582d172a6f3858b97c16da38b08da5a1df7101a5d285b868d89921b/0x534e5f4d41494e",
64
+ JSON.stringify({ policies: [] }),
65
+ );
66
+ localStorage.setItem("@cartridge/features", JSON.stringify({}));
67
+ localStorage.setItem(
68
+ "@cartridge/session/0x4fdcb829582d172a6f3858b97c16da38b08da5a1df7101a5d285b868d89921b/0x534e5f4d41494e",
69
+ JSON.stringify({ Session: {} }),
70
+ );
71
+ localStorage.setItem("keepMe", "keep");
72
+
73
+ await controller.disconnect();
74
+
75
+ expect(localStorage.getItem("lastUsedConnector")).toBeNull();
76
+ expect(
77
+ localStorage.getItem(
78
+ "@cartridge/account/0x40bda2fcd37963c0b8f951801c63a88132feb399dab0f5318245b2c59a553af/0x534e5f5345504f4c4941",
79
+ ),
80
+ ).toBeNull();
81
+ expect(localStorage.getItem("@cartridge/active")).toBeNull();
82
+ expect(
83
+ localStorage.getItem("@cartridge/https://x.cartridge.gg/active"),
84
+ ).toBeNull();
85
+ expect(
86
+ localStorage.getItem(
87
+ "@cartridge/policies/0x4fdcb829582d172a6f3858b97c16da38b08da5a1df7101a5d285b868d89921b/0x534e5f4d41494e",
88
+ ),
89
+ ).toBeNull();
90
+ expect(localStorage.getItem("@cartridge/features")).toBeNull();
91
+ expect(
92
+ localStorage.getItem(
93
+ "@cartridge/session/0x4fdcb829582d172a6f3858b97c16da38b08da5a1df7101a5d285b868d89921b/0x534e5f4d41494e",
94
+ ),
95
+ ).toBeNull();
96
+ expect(localStorage.getItem("keepMe")).toBe("keep");
97
+ expect(localStorage.removeItem).toHaveBeenCalledWith("lastUsedConnector");
98
+ expect(keychainDisconnect).toHaveBeenCalledTimes(1);
99
+ });
100
+
101
+ test("does not throw when localStorage is unavailable", async () => {
102
+ delete (global as any).localStorage;
103
+ const controller = new ControllerProvider({});
104
+ const keychainDisconnect = jest.fn().mockResolvedValue(undefined);
105
+ (controller as any).keychain = {
106
+ disconnect: keychainDisconnect,
107
+ };
108
+
109
+ await expect(controller.disconnect()).resolves.toBeUndefined();
110
+ expect(keychainDisconnect).toHaveBeenCalledTimes(1);
111
+ });
112
+ });
package/src/constants.ts CHANGED
@@ -1,3 +1,7 @@
1
1
  export const KEYCHAIN_URL = "https://x.cartridge.gg";
2
2
  export const PROFILE_URL = "https://profile.cartridge.gg";
3
3
  export const API_URL = "https://api.cartridge.gg";
4
+
5
+ // Query parameter name used to pass session data via URL redirects.
6
+ // Borrowed from Telegram mini app convention, but the choice is arbitrary.
7
+ export const REDIRECT_QUERY_NAME = "startapp";
package/src/controller.ts CHANGED
@@ -395,6 +395,13 @@ export default class ControllerProvider extends BaseProvider {
395
395
  try {
396
396
  if (typeof localStorage !== "undefined") {
397
397
  localStorage.removeItem("lastUsedConnector");
398
+
399
+ for (let i = localStorage.length - 1; i >= 0; i--) {
400
+ const key = localStorage.key(i);
401
+ if (key?.startsWith("@cartridge/")) {
402
+ localStorage.removeItem(key);
403
+ }
404
+ }
398
405
  }
399
406
  } catch {
400
407
  // Ignore environments where localStorage is unavailable.
@@ -93,17 +93,24 @@ export class KeychainIFrame extends IFrame<Keychain> {
93
93
  _url.searchParams.set("username", encodeURIComponent(username));
94
94
  }
95
95
 
96
+ if (preset) {
97
+ _url.searchParams.set("preset", preset);
98
+ }
99
+
100
+ if (shouldOverridePresetPolicies) {
101
+ _url.searchParams.set("should_override_preset_policies", "true");
102
+ }
103
+
96
104
  // Policy precedence logic:
97
105
  // 1. If shouldOverridePresetPolicies is true and policies are provided, use policies
98
- // 2. Otherwise, if preset is defined, use empty object (let preset take precedence)
99
- // 3. Otherwise, use provided policies or empty object
106
+ // 2. Otherwise, if preset is defined, ignore provided policies
107
+ // 3. Otherwise, use provided policies
100
108
  if ((!preset || shouldOverridePresetPolicies) && policies) {
101
109
  _url.searchParams.set(
102
110
  "policies",
103
111
  encodeURIComponent(JSON.stringify(policies)),
104
112
  );
105
113
  } else if (preset) {
106
- _url.searchParams.set("preset", preset);
107
114
  if (policies) {
108
115
  console.warn(
109
116
  "[Controller] Both `preset` and `policies` provided to ControllerProvider. " +
@@ -1,5 +1,6 @@
1
1
  import * as http from "http";
2
2
  import { AddressInfo } from "net";
3
+ import { REDIRECT_QUERY_NAME } from "../constants";
3
4
 
4
5
  type ServerResponse = http.ServerResponse<http.IncomingMessage> & {
5
6
  req: http.IncomingMessage;
@@ -38,7 +39,7 @@ export class CallbackServer {
38
39
  }
39
40
 
40
41
  const params = new URLSearchParams(req.url.split("?")[1]);
41
- const session = params.get("startapp");
42
+ const session = params.get(REDIRECT_QUERY_NAME);
42
43
 
43
44
  if (!session) {
44
45
  console.warn("Received callback without session data");
@@ -7,7 +7,7 @@ import {
7
7
  import { loadConfig, SessionPolicies } from "@cartridge/presets";
8
8
  import { AddStarknetChainParameters } from "@starknet-io/types-js";
9
9
  import { encode } from "starknet";
10
- import { API_URL, KEYCHAIN_URL } from "../constants";
10
+ import { API_URL, KEYCHAIN_URL, REDIRECT_QUERY_NAME } from "../constants";
11
11
  import { parsePolicies, ParsedSessionPolicies } from "../policies";
12
12
  import BaseProvider from "../provider";
13
13
  import { AuthOptions } from "../types";
@@ -50,12 +50,12 @@ export default class SessionProvider extends BaseProvider {
50
50
  protected _disconnectRedirectUrl?: string;
51
51
  protected _policies: ParsedSessionPolicies;
52
52
  protected _preset?: string;
53
- private _ready: Promise<void>;
54
53
  protected _keychainUrl: string;
55
54
  protected _apiUrl: string;
56
55
  protected _publicKey!: string;
57
56
  protected _sessionKeyGuid!: string;
58
57
  protected _signupOptions?: AuthOptions;
58
+ private _readyPromise: Promise<void>;
59
59
  public reopenBrowser: boolean = true;
60
60
 
61
61
  constructor({
@@ -102,35 +102,23 @@ export default class SessionProvider extends BaseProvider {
102
102
  this._apiUrl = apiUrl ?? API_URL;
103
103
  this._signupOptions = signupOptions;
104
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();
105
+ this._setSigningKeys();
106
+ this._readyPromise = this._resolvePreset();
109
107
 
110
108
  if (typeof window !== "undefined") {
111
109
  (window as any).starknet_controller_session = this;
112
110
  }
113
111
  }
114
112
 
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
-
132
- const account = this.tryRetrieveFromQueryOrStorage();
133
- if (!account) {
113
+ private _setSigningKeys(): void {
114
+ const signerString = localStorage.getItem("sessionSigner");
115
+ if (signerString) {
116
+ const signer = JSON.parse(signerString);
117
+ this._publicKey = signer.pubKey;
118
+ this._sessionKeyGuid = signerToGuid({
119
+ starknet: { privateKey: encode.addHexPrefix(signer.privKey) },
120
+ });
121
+ } else {
134
122
  const pk = stark.randomAddress();
135
123
  this._publicKey = ec.starkCurve.getStarkKey(pk);
136
124
 
@@ -144,20 +132,27 @@ export default class SessionProvider extends BaseProvider {
144
132
  this._sessionKeyGuid = signerToGuid({
145
133
  starknet: { privateKey: encode.addHexPrefix(pk) },
146
134
  });
147
- } else {
148
- const pk = localStorage.getItem("sessionSigner");
149
- if (!pk) throw new Error("failed to get sessionSigner");
135
+ }
136
+ }
150
137
 
151
- const jsonPk: {
152
- privKey: string;
153
- pubKey: string;
154
- } = JSON.parse(pk);
138
+ // Resolve preset policies asynchronously.
139
+ // Public methods await this before proceeding.
140
+ private async _resolvePreset(): Promise<void> {
141
+ if (!this._preset) return;
155
142
 
156
- this._publicKey = jsonPk.pubKey;
157
- this._sessionKeyGuid = signerToGuid({
158
- starknet: { privateKey: encode.addHexPrefix(jsonPk.privKey) },
159
- });
143
+ const config = await loadConfig(this._preset);
144
+ if (!config) {
145
+ throw new Error(`Failed to load preset: ${this._preset}`);
160
146
  }
147
+
148
+ const sessionPolicies = getPresetSessionPolicies(config, this._chainId);
149
+ if (!sessionPolicies) {
150
+ throw new Error(
151
+ `No policies found for chain ${this._chainId} in preset ${this._preset}`,
152
+ );
153
+ }
154
+
155
+ this._policies = parsePolicies(sessionPolicies);
161
156
  }
162
157
 
163
158
  private validatePoliciesSubset(
@@ -247,17 +242,31 @@ export default class SessionProvider extends BaseProvider {
247
242
  }
248
243
 
249
244
  async username() {
250
- await this._ready;
245
+ await this._readyPromise;
246
+
247
+ if (!this.account) {
248
+ this.tryRetrieveSessionAccount();
249
+ }
250
+
251
251
  return this._username;
252
252
  }
253
253
 
254
254
  async probe(): Promise<WalletAccount | undefined> {
255
- await this._ready;
255
+ await this._readyPromise;
256
+
257
+ if (!this.account) {
258
+ this.tryRetrieveSessionAccount();
259
+ }
260
+
256
261
  return this.account;
257
262
  }
258
263
 
259
264
  async connect(): Promise<WalletAccount | undefined> {
260
- await this._ready;
265
+ await this._readyPromise;
266
+
267
+ if (!this.account) {
268
+ this.tryRetrieveSessionAccount();
269
+ }
261
270
 
262
271
  if (this.account) {
263
272
  return this.account;
@@ -319,7 +328,7 @@ export default class SessionProvider extends BaseProvider {
319
328
  };
320
329
  localStorage.setItem("session", JSON.stringify(session));
321
330
 
322
- this.tryRetrieveFromQueryOrStorage();
331
+ this.tryRetrieveSessionAccount();
323
332
 
324
333
  return this.account;
325
334
  } catch (e) {
@@ -367,15 +376,15 @@ export default class SessionProvider extends BaseProvider {
367
376
  return promise;
368
377
  }
369
378
 
370
- tryRetrieveFromQueryOrStorage() {
379
+ // Try to retrieve the session account from localStorage or URL query params
380
+ private tryRetrieveSessionAccount() {
371
381
  if (this.account) {
372
382
  return this.account;
373
383
  }
374
384
 
375
- const signerString = localStorage.getItem("sessionSigner");
376
- const signer = signerString ? JSON.parse(signerString) : null;
377
385
  let sessionRegistration: SessionRegistration | null = null;
378
386
 
387
+ // Load session from localStorage (saved by ingestSessionFromRedirect or connect)
379
388
  const sessionString = localStorage.getItem("session");
380
389
  if (sessionString) {
381
390
  const parsed = JSON.parse(sessionString) as Partial<SessionRegistration>;
@@ -388,9 +397,9 @@ export default class SessionProvider extends BaseProvider {
388
397
  }
389
398
  }
390
399
 
391
- if (window.location.search.includes("startapp")) {
400
+ if (window.location.search.includes(REDIRECT_QUERY_NAME)) {
392
401
  const params = new URLSearchParams(window.location.search);
393
- const session = params.get("startapp");
402
+ const session = params.get(REDIRECT_QUERY_NAME);
394
403
  if (session) {
395
404
  const normalizedSession = this.ingestSessionFromRedirect(session);
396
405
  if (
@@ -402,7 +411,7 @@ export default class SessionProvider extends BaseProvider {
402
411
  }
403
412
 
404
413
  // Remove the session query parameter
405
- params.delete("startapp");
414
+ params.delete(REDIRECT_QUERY_NAME);
406
415
  const newUrl =
407
416
  window.location.pathname +
408
417
  (params.toString() ? `?${params.toString()}` : "") +
@@ -411,6 +420,9 @@ export default class SessionProvider extends BaseProvider {
411
420
  }
412
421
  }
413
422
 
423
+ const signerString = localStorage.getItem("sessionSigner");
424
+ const signer = signerString ? JSON.parse(signerString) : null;
425
+
414
426
  if (!sessionRegistration || !signer) {
415
427
  return;
416
428
  }
@@ -1,79 +0,0 @@
1
-
2
- > @cartridge/controller@0.13.7 build /home/runner/work/controller/controller/packages/controller
3
- > pnpm build:browser && pnpm build:node
4
-
5
-
6
- > @cartridge/controller@0.13.7 build:browser /home/runner/work/controller/controller/packages/controller
7
- > vite build
8
-
9
- vite v6.3.4 building for production...
10
- src/lookup.ts:116:7 - error TS2322: Type 'string[]' is not assignable to type '("base" | "metamask" | "rabby" | "phantom-evm" | "argent" | "braavos" | "phantom" | "google" | "webauthn" | "discord" | "walletconnect" | "password")[]'.
11
- Type 'string' is not assignable to type '"base" | "metamask" | "rabby" | "phantom-evm" | "argent" | "braavos" | "phantom" | "google" | "webauthn" | "discord" | "walletconnect" | "password"'.
12
-
13
- 116 const HEADLESS_AUTH_OPTIONS: AuthOption[] = [
14
-    ~~~~~~~~~~~~~~~~~~~~~
15
- src/lookup.ts:125:56 - error TS2345: Argument of type 'string' is not assignable to parameter of type '"metamask" | "rabby" | "phantom-evm" | "google" | "webauthn" | "discord" | "walletconnect" | "password"'.
16
-
17
- 125 ].filter((option) => IMPLEMENTED_AUTH_OPTIONS.includes(option));
18
-    ~~~~~~
19
-
20
- transforming...
21
- ../../node_modules/.pnpm/ox@0.4.4_typescript@5.8.3_zod@3.24.4/node_modules/ox/_esm/core/Address.js (6:21): A comment
22
-
23
- "/*#__PURE__*/"
24
-
25
- in "../../node_modules/.pnpm/ox@0.4.4_typescript@5.8.3_zod@3.24.4/node_modules/ox/_esm/core/Address.js" contains an annotation that Rollup cannot interpret due to the position of the comment. The comment will be removed to avoid issues.
26
- ../../node_modules/.pnpm/ox@0.4.4_typescript@5.8.3_zod@3.24.4/node_modules/ox/_esm/core/Base64.js (6:27): A comment
27
-
28
- "/*#__PURE__*/"
29
-
30
- in "../../node_modules/.pnpm/ox@0.4.4_typescript@5.8.3_zod@3.24.4/node_modules/ox/_esm/core/Base64.js" contains an annotation that Rollup cannot interpret due to the position of the comment. The comment will be removed to avoid issues.
31
- ../../node_modules/.pnpm/ox@0.4.4_typescript@5.8.3_zod@3.24.4/node_modules/ox/_esm/core/Json.js (1:21): A comment
32
-
33
- "/*#__PURE__*/"
34
-
35
- in "../../node_modules/.pnpm/ox@0.4.4_typescript@5.8.3_zod@3.24.4/node_modules/ox/_esm/core/Json.js" contains an annotation that Rollup cannot interpret due to the position of the comment. The comment will be removed to avoid issues.
36
- ../../node_modules/.pnpm/ox@0.4.4_typescript@5.8.3_zod@3.24.4/node_modules/ox/_esm/core/internal/cursor.js (2:21): A comment
37
-
38
- "/*#__PURE__*/"
39
-
40
- in "../../node_modules/.pnpm/ox@0.4.4_typescript@5.8.3_zod@3.24.4/node_modules/ox/_esm/core/internal/cursor.js" contains an annotation that Rollup cannot interpret due to the position of the comment. The comment will be removed to avoid issues.
41
- ✓ 389 modules transformed.
42
- rendering chunks...
43
- [plugin vite:reporter]
44
- (!) /home/runner/work/controller/controller/packages/controller/src/toast/index.ts is dynamically imported by /home/runner/work/controller/controller/packages/controller/src/account.ts but also statically imported by /home/runner/work/controller/controller/packages/controller/src/index.ts, dynamic import will not move module into another chunk.
45
- 
46
-
47
- [vite:dts] Start generate declaration files...
48
- computing gzip size...
49
- dist/session.js  10.53 kB │ gzip: 3.24 kB │ map: 25.46 kB
50
- dist/index-CYAUAqql.js  38.34 kB │ gzip: 12.80 kB │ map: 81.12 kB
51
- dist/index.js 188.96 kB │ gzip: 49.77 kB │ map: 576.04 kB
52
- [vite:dts] Declaration files built in 3619ms.
53
-
54
- ✓ built in 5.80s
55
-
56
- > @cartridge/controller@0.13.7 build:node /home/runner/work/controller/controller/packages/controller
57
- > tsup --config tsup.node.config.ts
58
-
59
- CLI Building entry: src/node/index.ts
60
- CLI Using tsconfig: tsconfig.json
61
- CLI tsup v8.4.0
62
- CLI Using tsup config: /home/runner/work/controller/controller/packages/controller/tsup.node.config.ts
63
- CLI Target: esnext
64
- CLI Cleaning output folder
65
- ESM Build start
66
- CJS Build start
67
- "constants" and "shortString" are imported from external module "starknet" but never used in "dist/node/index.js".
68
- "constants" and "shortString" are imported from external module "starknet" but never used in "dist/node/index.cjs".
69
- Entry module "dist/node/index.cjs" is using named and default exports together. Consumers of your bundle will have to use `chunk.default` to access the default export, which may not be what you want. Use `output.exports: "named"` to disable this warning.
70
- ESM dist/node/index.js 23.50 KB
71
- ESM dist/node/index.js.map 56.57 KB
72
- ESM ⚡️ Build success in 289ms
73
- CJS dist/node/index.cjs 24.40 KB
74
- CJS dist/node/index.cjs.map 56.82 KB
75
- CJS ⚡️ Build success in 289ms
76
- DTS Build start
77
- DTS ⚡️ Build success in 3087ms
78
- DTS dist/node/index.d.ts 6.42 KB
79
- DTS dist/node/index.d.cts 6.42 KB