@cartridge/controller 0.13.6 → 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/dist/types.d.ts CHANGED
@@ -70,6 +70,11 @@ export interface LookupResult {
70
70
  export interface LookupResponse {
71
71
  results: LookupResult[];
72
72
  }
73
+ export interface HeadlessUsernameLookupResult {
74
+ username: string;
75
+ exists: boolean;
76
+ signers: AuthOption[];
77
+ }
73
78
  export declare enum FeeSource {
74
79
  PAYMASTER = "PAYMASTER",
75
80
  CREDITS = "CREDITS"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartridge/controller",
3
- "version": "0.13.6",
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.6"
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
+ });
@@ -0,0 +1,166 @@
1
+ import { constants } from "starknet";
2
+ import ControllerProvider from "../controller";
3
+ import { IMPLEMENTED_AUTH_OPTIONS } from "../types";
4
+
5
+ describe("lookupUsername", () => {
6
+ beforeEach(() => {
7
+ (global as any).fetch = jest.fn();
8
+ });
9
+
10
+ afterEach(() => {
11
+ jest.resetAllMocks();
12
+ });
13
+
14
+ test("returns normalized signer options in canonical order", async () => {
15
+ (global.fetch as jest.Mock).mockResolvedValue({
16
+ ok: true,
17
+ json: async () => ({
18
+ data: {
19
+ account: {
20
+ username: "alice",
21
+ controllers: {
22
+ edges: [
23
+ {
24
+ node: {
25
+ signers: [
26
+ {
27
+ isOriginal: true,
28
+ isRevoked: false,
29
+ metadata: {
30
+ __typename: "Eip191Credentials",
31
+ eip191: [
32
+ { provider: "metamask", ethAddress: "0x1" },
33
+ { provider: "google", ethAddress: "0x2" },
34
+ { provider: "unknown", ethAddress: "0x3" },
35
+ ],
36
+ },
37
+ },
38
+ {
39
+ isOriginal: true,
40
+ isRevoked: false,
41
+ metadata: { __typename: "PasswordCredentials" },
42
+ },
43
+ {
44
+ isOriginal: true,
45
+ isRevoked: false,
46
+ metadata: { __typename: "WebauthnCredentials" },
47
+ },
48
+ {
49
+ isOriginal: true,
50
+ isRevoked: true,
51
+ metadata: { __typename: "WebauthnCredentials" },
52
+ },
53
+ {
54
+ isOriginal: true,
55
+ isRevoked: false,
56
+ metadata: {
57
+ __typename: "Eip191Credentials",
58
+ eip191: [{ provider: "discord", ethAddress: "0x4" }],
59
+ },
60
+ },
61
+ ],
62
+ },
63
+ },
64
+ ],
65
+ },
66
+ },
67
+ },
68
+ }),
69
+ });
70
+
71
+ const provider = new ControllerProvider({ lazyload: true });
72
+ const result = await provider.lookupUsername("alice");
73
+ const expectedOrder = IMPLEMENTED_AUTH_OPTIONS.filter((option) =>
74
+ ["google", "webauthn", "discord", "password", "metamask"].includes(
75
+ option,
76
+ ),
77
+ );
78
+
79
+ expect(result).toEqual({
80
+ username: "alice",
81
+ exists: true,
82
+ signers: expectedOrder,
83
+ });
84
+ expect(global.fetch).toHaveBeenCalledWith(
85
+ "https://api.cartridge.gg/query",
86
+ expect.any(Object),
87
+ );
88
+ });
89
+
90
+ test("filters non-original signers on non-mainnet chains", async () => {
91
+ (global.fetch as jest.Mock).mockResolvedValue({
92
+ ok: true,
93
+ json: async () => ({
94
+ data: {
95
+ account: {
96
+ username: "alice",
97
+ controllers: {
98
+ edges: [
99
+ {
100
+ node: {
101
+ signers: [
102
+ {
103
+ isOriginal: false,
104
+ isRevoked: false,
105
+ metadata: {
106
+ __typename: "Eip191Credentials",
107
+ eip191: [{ provider: "google", ethAddress: "0x1" }],
108
+ },
109
+ },
110
+ {
111
+ isOriginal: true,
112
+ isRevoked: false,
113
+ metadata: { __typename: "PasswordCredentials" },
114
+ },
115
+ ],
116
+ },
117
+ },
118
+ ],
119
+ },
120
+ },
121
+ },
122
+ }),
123
+ });
124
+
125
+ const provider = new ControllerProvider({
126
+ lazyload: true,
127
+ defaultChainId: constants.StarknetChainId.SN_SEPOLIA,
128
+ });
129
+ const result = await provider.lookupUsername("alice");
130
+
131
+ expect(result.signers).toEqual(["password"]);
132
+ });
133
+
134
+ test("returns exists=false for unknown usernames", async () => {
135
+ (global.fetch as jest.Mock).mockResolvedValue({
136
+ ok: true,
137
+ json: async () => ({
138
+ data: {
139
+ account: null,
140
+ },
141
+ }),
142
+ });
143
+
144
+ const provider = new ControllerProvider({ lazyload: true });
145
+ const result = await provider.lookupUsername("missing-user");
146
+
147
+ expect(result).toEqual({
148
+ username: "missing-user",
149
+ exists: false,
150
+ signers: [],
151
+ });
152
+ });
153
+
154
+ test("throws on network failures", async () => {
155
+ (global.fetch as jest.Mock).mockResolvedValue({
156
+ ok: false,
157
+ status: 503,
158
+ });
159
+
160
+ const provider = new ControllerProvider({ lazyload: true });
161
+
162
+ await expect(provider.lookupUsername("alice")).rejects.toThrow(
163
+ "HTTP error! status: 503",
164
+ );
165
+ });
166
+ });
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
@@ -15,6 +15,7 @@ import { KEYCHAIN_URL } from "./constants";
15
15
  import { HeadlessAuthenticationError, NotReadyToConnect } from "./errors";
16
16
  import { KeychainIFrame } from "./iframe";
17
17
  import BaseProvider from "./provider";
18
+ import { lookupUsername as lookupUsernameApi } from "./lookup";
18
19
  import {
19
20
  AuthOptions,
20
21
  Chain,
@@ -28,6 +29,7 @@ import {
28
29
  ProfileContextTypeVariant,
29
30
  ResponseCodes,
30
31
  OpenOptions,
32
+ HeadlessUsernameLookupResult,
31
33
  StarterpackOptions,
32
34
  } from "./types";
33
35
  import { validateRedirectUrl } from "./url-validator";
@@ -393,6 +395,13 @@ export default class ControllerProvider extends BaseProvider {
393
395
  try {
394
396
  if (typeof localStorage !== "undefined") {
395
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
+ }
396
405
  }
397
406
  } catch {
398
407
  // Ignore environments where localStorage is unavailable.
@@ -530,6 +539,17 @@ export default class ControllerProvider extends BaseProvider {
530
539
  return this.keychain.username();
531
540
  }
532
541
 
542
+ async lookupUsername(
543
+ username: string,
544
+ ): Promise<HeadlessUsernameLookupResult> {
545
+ const trimmed = username.trim();
546
+ if (!trimmed) {
547
+ throw new Error("Username is required");
548
+ }
549
+
550
+ return lookupUsernameApi(trimmed, this.selectedChain);
551
+ }
552
+
533
553
  openPurchaseCredits() {
534
554
  if (!this.iframes) {
535
555
  return;
@@ -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. " +
package/src/lookup.ts CHANGED
@@ -1,8 +1,43 @@
1
- import { LookupRequest, LookupResponse } from "./types";
2
- import { num } from "starknet";
1
+ import {
2
+ AuthOption,
3
+ HeadlessUsernameLookupResult,
4
+ IMPLEMENTED_AUTH_OPTIONS,
5
+ LookupRequest,
6
+ LookupResponse,
7
+ } from "./types";
8
+ import { constants, num } from "starknet";
3
9
  import { API_URL } from "./constants";
4
10
 
5
11
  const cache = new Map<string, string>();
12
+ const QUERY_URL = `${API_URL}/query`;
13
+
14
+ type LookupSigner = {
15
+ isOriginal: boolean;
16
+ isRevoked: boolean;
17
+ metadata: {
18
+ __typename: string;
19
+ eip191?: Array<{
20
+ provider: string;
21
+ ethAddress: string;
22
+ }> | null;
23
+ };
24
+ };
25
+
26
+ type LookupSignersQueryResponse = {
27
+ data?: {
28
+ account?: {
29
+ username: string;
30
+ controllers?: {
31
+ edges?: Array<{
32
+ node?: {
33
+ signers?: LookupSigner[] | null;
34
+ } | null;
35
+ } | null> | null;
36
+ } | null;
37
+ } | null;
38
+ };
39
+ errors?: Array<{ message?: string }>;
40
+ };
6
41
 
7
42
  async function lookup(request: LookupRequest): Promise<LookupResponse> {
8
43
  if (!request.addresses?.length && !request.usernames?.length) {
@@ -24,6 +59,139 @@ async function lookup(request: LookupRequest): Promise<LookupResponse> {
24
59
  return response.json();
25
60
  }
26
61
 
62
+ async function queryLookupSigners(
63
+ username: string,
64
+ ): Promise<LookupSignersQueryResponse> {
65
+ const response = await fetch(QUERY_URL, {
66
+ method: "POST",
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ },
70
+ body: JSON.stringify({
71
+ query: `
72
+ query LookupSigners($username: String!) {
73
+ account(username: $username) {
74
+ username
75
+ controllers(first: 1) {
76
+ edges {
77
+ node {
78
+ signers {
79
+ isOriginal
80
+ isRevoked
81
+ metadata {
82
+ __typename
83
+ ... on Eip191Credentials {
84
+ eip191 {
85
+ provider
86
+ ethAddress
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ `,
97
+ variables: { username },
98
+ }),
99
+ });
100
+
101
+ if (!response.ok) {
102
+ throw new Error(`HTTP error! status: ${response.status}`);
103
+ }
104
+
105
+ return response.json();
106
+ }
107
+
108
+ function normalizeProvider(provider: string): AuthOption | undefined {
109
+ const normalized = provider.toLowerCase() as AuthOption;
110
+ if (!HEADLESS_AUTH_OPTIONS.includes(normalized)) {
111
+ return undefined;
112
+ }
113
+ return normalized;
114
+ }
115
+
116
+ const HEADLESS_AUTH_OPTIONS: AuthOption[] = [
117
+ "google",
118
+ "webauthn",
119
+ "discord",
120
+ "walletconnect",
121
+ "password",
122
+ "metamask",
123
+ "rabby",
124
+ "phantom-evm",
125
+ ].filter((option) => IMPLEMENTED_AUTH_OPTIONS.includes(option));
126
+
127
+ function normalizeSignerOptions(
128
+ signers: LookupSigner[] | undefined,
129
+ chainId: string,
130
+ ): AuthOption[] {
131
+ if (!signers || signers.length === 0) {
132
+ return [];
133
+ }
134
+
135
+ const isMainnet = chainId === constants.StarknetChainId.SN_MAIN;
136
+ const available = signers.filter(
137
+ (signer) => !signer.isRevoked && (isMainnet || signer.isOriginal),
138
+ );
139
+
140
+ const signerSet = new Set<AuthOption>();
141
+ for (const signer of available) {
142
+ switch (signer.metadata.__typename) {
143
+ case "WebauthnCredentials":
144
+ signerSet.add("webauthn");
145
+ break;
146
+ case "PasswordCredentials":
147
+ signerSet.add("password");
148
+ break;
149
+ case "Eip191Credentials":
150
+ signer.metadata.eip191?.forEach((entry) => {
151
+ const provider = normalizeProvider(entry.provider);
152
+ if (provider) {
153
+ signerSet.add(provider);
154
+ }
155
+ });
156
+ break;
157
+ default:
158
+ break;
159
+ }
160
+ }
161
+
162
+ return HEADLESS_AUTH_OPTIONS.filter((option) => signerSet.has(option));
163
+ }
164
+
165
+ export async function lookupUsername(
166
+ username: string,
167
+ chainId: string,
168
+ ): Promise<HeadlessUsernameLookupResult> {
169
+ const response = await queryLookupSigners(username);
170
+ if (response.errors?.length) {
171
+ throw new Error(response.errors[0].message || "Lookup query failed");
172
+ }
173
+
174
+ const account = response.data?.account;
175
+ if (!account) {
176
+ return {
177
+ username,
178
+ exists: false,
179
+ signers: [],
180
+ };
181
+ }
182
+
183
+ const controller = account.controllers?.edges?.[0]?.node;
184
+ const signers = normalizeSignerOptions(
185
+ controller?.signers ?? undefined,
186
+ chainId,
187
+ );
188
+ return {
189
+ username: account.username,
190
+ exists: true,
191
+ signers,
192
+ };
193
+ }
194
+
27
195
  export async function lookupUsernames(
28
196
  usernames: string[],
29
197
  ): Promise<Map<string, string>> {
@@ -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");