Skip to content

Commit e0ab711

Browse files
authored
Add headless username lookup and auto-signup connect (#2400)
This adds `controller.lookupUsername(username)` for headless flows, returning whether an account exists and normalized signer options for the controller’s configured chain. It also updates headless connect to auto-signup when a username is missing, while keeping strict signer matching for existing accounts. The connector now exposes the lookup helper, and docs were updated with the recommended lookup-first flow. Tests were added for lookup normalization/error handling and for password-based headless auto-signup.
1 parent 12761e1 commit e0ab711

File tree

8 files changed

+834
-49
lines changed

8 files changed

+834
-49
lines changed

packages/connector/src/controller.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export default class ControllerConnector extends InjectedConnector {
3838
return this.controller.username();
3939
}
4040

41+
lookupUsername(username: string) {
42+
return this.controller.lookupUsername(username);
43+
}
44+
4145
isReady(): boolean {
4246
return this.controller.isReady();
4347
}

packages/controller/HEADLESS_MODE.md

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,37 @@
22

33
## Overview
44

5-
Headless mode enables programmatic authentication with the Cartridge Controller SDK without displaying any UI. You trigger headless mode by passing a `username` and `signer` to `connect(...)`.
5+
Headless mode enables programmatic authentication with the Cartridge Controller
6+
SDK without displaying any UI. You trigger headless mode by passing a `username`
7+
and `signer` to `connect(...)`.
68

79
```
810
Controller SDK → Keychain iframe (hidden) → Backend API
911
```
1012

1113
**Key Points**
14+
1215
- The keychain iframe still exists, but the modal is not opened.
1316
- The SDK passes the connect request to keychain over Penpal.
1417
- Keychain executes the same authentication logic as the UI flow.
1518
- No duplicated auth logic in the SDK.
1619

1720
## Usage
1821

22+
### Username Lookup (Recommended)
23+
24+
Use lookup first so your app can decide whether to login or signup and show the
25+
available signer methods for existing accounts.
26+
27+
```ts
28+
const lookup = await controller.lookupUsername("alice");
29+
30+
if (lookup.exists) {
31+
// e.g. ["webauthn", "google", "password"]
32+
console.log(lookup.signers);
33+
}
34+
```
35+
1936
### Basic (Passkey / WebAuthn)
2037

2138
```ts
@@ -31,6 +48,9 @@ await controller.connect({
3148
});
3249
```
3350

51+
If `alice` does not exist yet, headless connect will create a new account and
52+
continue.
53+
3454
### Password
3555

3656
```ts
@@ -60,6 +80,7 @@ await controller.connect({ username: "alice", signer: "walletconnect" });
6080
## Supported Auth Options
6181

6282
Headless mode supports all **implemented** auth options:
83+
6384
- `webauthn`
6485
- `password`
6586
- `google`
@@ -72,8 +93,8 @@ Headless mode supports all **implemented** auth options:
7293
## Handling Session Approval
7394

7495
If policies are unverified or include approvals, Keychain will prompt for
75-
session approval **after** authentication. In that case, `connect` will open
76-
the approval UI and resolve once the session is approved.
96+
session approval **after** authentication. In that case, `connect` will open the
97+
approval UI and resolve once the session is approved.
7798

7899
```ts
79100
const account = await controller.connect({
@@ -83,6 +104,7 @@ const account = await controller.connect({
83104

84105
console.log("Session approved:", account.address);
85106
```
107+
86108
If you want to react to connection state changes, subscribe to the standard
87109
wallet events (for example `accountsChanged`) or just await `connect(...)` and
88110
update your app state afterwards.
@@ -92,9 +114,7 @@ update your app state afterwards.
92114
The SDK provides specific error classes for headless mode:
93115

94116
```ts
95-
import {
96-
HeadlessAuthenticationError,
97-
} from "@cartridge/controller";
117+
import { HeadlessAuthenticationError } from "@cartridge/controller";
98118

99119
try {
100120
await controller.connect({ username: "alice", signer: "webauthn" });
@@ -107,7 +127,8 @@ try {
107127

108128
## Notes
109129

110-
- Headless mode uses the **existing signers** on the controller for the given username.
130+
- Headless mode can create new accounts when the username does not exist.
131+
- For existing accounts, the requested signer must already be registered.
111132
- For passkeys, the account must already have a WebAuthn signer registered.
112133
- If policies are unverified or include approvals, Keychain will request
113134
explicit approval after authentication.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

packages/controller/src/controller.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { KEYCHAIN_URL } from "./constants";
1515
import { HeadlessAuthenticationError, NotReadyToConnect } from "./errors";
1616
import { KeychainIFrame } from "./iframe";
1717
import BaseProvider from "./provider";
18+
import { lookupUsername as lookupUsernameApi } from "./lookup";
1819
import {
1920
AuthOptions,
2021
Chain,
@@ -28,6 +29,7 @@ import {
2829
ProfileContextTypeVariant,
2930
ResponseCodes,
3031
OpenOptions,
32+
HeadlessUsernameLookupResult,
3133
StarterpackOptions,
3234
} from "./types";
3335
import { validateRedirectUrl } from "./url-validator";
@@ -530,6 +532,17 @@ export default class ControllerProvider extends BaseProvider {
530532
return this.keychain.username();
531533
}
532534

535+
async lookupUsername(
536+
username: string,
537+
): Promise<HeadlessUsernameLookupResult> {
538+
const trimmed = username.trim();
539+
if (!trimmed) {
540+
throw new Error("Username is required");
541+
}
542+
543+
return lookupUsernameApi(trimmed, this.selectedChain);
544+
}
545+
533546
openPurchaseCredits() {
534547
if (!this.iframes) {
535548
return;

0 commit comments

Comments
 (0)