@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.
- package/.turbo/turbo-build$colon$deps.log +41 -21
- package/.turbo/turbo-build.log +40 -20
- package/HEADLESS_MODE.md +113 -0
- package/dist/controller.d.ts +9 -2
- package/dist/errors.d.ts +10 -0
- package/dist/iframe/security.d.ts +10 -0
- package/dist/index-BdTFKueB.js +1072 -0
- package/dist/index-BdTFKueB.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2273 -2524
- package/dist/index.js.map +1 -1
- package/dist/node/index.cjs +30 -5
- package/dist/node/index.cjs.map +1 -1
- package/dist/node/index.d.cts +11 -1
- package/dist/node/index.d.ts +11 -1
- package/dist/node/index.js +29 -7
- package/dist/node/index.js.map +1 -1
- package/dist/session/provider.d.ts +8 -2
- package/dist/session.js +141 -139
- package/dist/session.js.map +1 -1
- package/dist/stats.html +1 -1
- package/dist/types.d.ts +20 -1
- package/dist/utils.d.ts +5 -3
- package/package.json +4 -3
- package/src/__tests__/asWalletStandard.test.ts +87 -0
- package/src/__tests__/headlessConnectApproval.test.ts +97 -0
- package/src/__tests__/iframeSecurity.test.ts +84 -0
- package/src/__tests__/parseChainId.test.ts +1 -1
- package/src/__tests__/toWasmPolicies.test.ts +89 -40
- package/src/controller.ts +165 -13
- package/src/errors.ts +30 -0
- package/src/iframe/base.ts +14 -3
- package/src/iframe/keychain.ts +8 -5
- package/src/iframe/security.ts +48 -0
- package/src/index.ts +1 -0
- package/src/session/provider.ts +77 -48
- package/src/types.ts +30 -1
- package/src/utils.ts +21 -7
- package/dist/provider-DSqqvDee.js +0 -369
- 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
|
-
|
|
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 =
|
|
256
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|
package/src/iframe/base.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
})
|
|
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());
|
package/src/iframe/keychain.ts
CHANGED
|
@@ -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
package/src/session/provider.ts
CHANGED
|
@@ -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
|
|
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
|
|
52
|
-
protected _sessionKeyGuid
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
250
|
+
await this._ready;
|
|
221
251
|
return this._username;
|
|
222
252
|
}
|
|
223
253
|
|
|
224
254
|
async probe(): Promise<WalletAccount | undefined> {
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
this._redirectUrl
|
|
265
|
-
|
|
266
|
-
this.
|
|
267
|
-
|
|
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(
|
|
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;
|