Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
693c04c
feat: add headless mode support to controller SDK
tarrencev Jan 8, 2026
f1cb115
fix: format types.ts with prettier
tarrencev Jan 8, 2026
b54844f
docs: remove IMPLEMENTATION_SUMMARY.md
tarrencev Jan 8, 2026
615682a
feat: add keychain headless mode framework
tarrencev Jan 8, 2026
3ab4a2e
refactor: combine headless params into single ConnectOptions
tarrencev Jan 9, 2026
bdb0146
feat: implement headless authentication backend
tarrencev Jan 9, 2026
c103938
refactor: update HeadlessLogin example to use Passkey and MetaMask
tarrencev Jan 9, 2026
8656078
fix: resolve window.ethereum type conflict in HeadlessLogin
tarrencev Jan 9, 2026
907c94d
fix: ensure keychain connection is ready before connect
tarrencev Jan 9, 2026
0158819
docs: remove headless-simple.ts example file
tarrencev Jan 9, 2026
21c25ff
Add debug logging for headless MetaMask authentication
tarrencev Jan 12, 2026
2714939
feat: support headless connect via options
tarrencev Jan 28, 2026
e01ebfc
chore: fix example lint and deps
tarrencev Jan 28, 2026
effe593
test: stabilize keychain e2e selectors
tarrencev Jan 28, 2026
8bb03b5
feat: stabilize headless connect flow
tarrencev Jan 29, 2026
9532503
fix: address code scanning review comments
tarrencev Jan 29, 2026
6038c73
fix: avoid treating usernames as rpc urls
tarrencev Jan 29, 2026
482e8f8
fix: type route params and update mocks
tarrencev Jan 29, 2026
8e06ab8
feat: move headless login into modal
tarrencev Feb 4, 2026
54f1976
Merge remote-tracking branch 'origin/main' into head2
tarrencev Feb 4, 2026
7cae433
address review: gate headless example
tarrencev Feb 5, 2026
c99de75
allow headless with unverified policies
tarrencev Feb 5, 2026
e579310
support headless approvals
tarrencev Feb 5, 2026
84ef51b
Fix headless approval UI timing
tarrencev Feb 5, 2026
9604de7
Fix controller parseChainId test URL
tarrencev Feb 5, 2026
e0e7ea5
Merge origin/main
tarrencev Feb 5, 2026
be48feb
Open headless approval UI after login
tarrencev Feb 5, 2026
07b9e7b
Open headless approval modal after parent ready
tarrencev Feb 5, 2026
592d5df
Merge remote-tracking branch 'origin/main' into head2
tarrencev Feb 5, 2026
c0f8d62
Move e2e mocks into test harness
tarrencev Feb 5, 2026
a97684a
Sync headless login with connector state
tarrencev Feb 6, 2026
c1b7240
Merge remote-tracking branch 'origin/main' into head2
tarrencev Feb 6, 2026
4d48b7d
Remove e2e harness from headless changes
tarrencev Feb 6, 2026
9b63d9a
Clear connect params after session approval
tarrencev Feb 6, 2026
7fe0a1d
feat: rework headless approval flow
tarrencev Feb 6, 2026
1c2d22d
Merge remote-tracking branch 'origin/main' into head2
tarrencev Feb 6, 2026
5935216
fix: address headless build errors
tarrencev Feb 6, 2026
b76d2ff
fix: sync app after headless session approval
tarrencev Feb 6, 2026
026307f
fix: resolve headless connect after session approval
tarrencev Feb 9, 2026
6e9fa0a
fix: sync starknet-react after headless login
tarrencev Feb 9, 2026
bad2801
fix: sync headless login and add regression test
tarrencev Feb 9, 2026
abd9f15
Merge remote-tracking branch 'origin/main' into head2
tarrencev Feb 9, 2026
b64863f
fix(headless): await approval via connect()
tarrencev Feb 9, 2026
53503ba
ci: keep claude-code-review workflow unchanged on PRs
tarrencev Feb 9, 2026
fe3bda3
refactor(keychain): centralize connect routing + session creation
tarrencev Feb 9, 2026
3f32f4c
chore(keychain): limit vitest coverage to src
tarrencev Feb 9, 2026
dce785f
test(keychain): improve connection util coverage
tarrencev Feb 9, 2026
16b2002
fix: stabilize connector connect and Claude review CI
tarrencev Feb 9, 2026
bae5faf
ci: allow Claude review to run gh pr commands
tarrencev Feb 9, 2026
c823a80
refactor: simplify headless API and disconnect semantics
tarrencev Feb 9, 2026
1e0b647
fix(keychain): clear controller-wasm storage on disconnect
tarrencev Feb 10, 2026
b4bdfb1
refactor(keychain): store headless approval waiters in callbacks regi…
tarrencev Feb 10, 2026
fc9dd6c
Revert "fix(keychain): clear controller-wasm storage on disconnect"
tarrencev Feb 10, 2026
e86296e
Merge remote-tracking branch 'origin/main' into head2
tarrencev Feb 10, 2026
48e39ae
fix(keychain): update session test mock connection
tarrencev Feb 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"Bash(pnpm:storybook:*)",
"Bash(pnpm:e2e:*)",
"Bash(pnpm:--filter:*)",
"Bash(gh:pr:*)",
"Bash(git:status)",
"Bash(git:diff)",
"Bash(git:log)",
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ htmlcov/
.cache
nosetests.xml
coverage.xml
**/coverage/
*.cover
*.py,cover
.hypothesis/
Expand Down
40 changes: 40 additions & 0 deletions examples/next/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { useState, useEffect, useRef, useMemo } from "react";
import { constants, num, shortString } from "starknet";
import { Chain } from "@starknet-react/chains";
import SessionConnector from "@cartridge/connector/session";
import { HeadlessLogin } from "components/HeadlessLogin";

type HeadlessModalState = "closed" | "open" | "hidden";

const Header = () => {
const { connect, connectors } = useConnect();
Expand All @@ -21,6 +24,8 @@ const Header = () => {
const { address, status } = useAccount();
const [networkOpen, setNetworkOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const [headlessState, setHeadlessState] =
useState<HeadlessModalState>("closed");
const [isControllerReady, setIsControllerReady] = useState(false);
const { switchChain } = useSwitchChain({
params: {
Expand Down Expand Up @@ -188,6 +193,12 @@ const Header = () => {
>
Standalone
</Button>
<Button
onClick={() => setHeadlessState("open")}
disabled={!isControllerReady}
>
Headless
</Button>
<Button
onClick={() => {
connect({ connector: controllerConnector });
Expand Down Expand Up @@ -217,6 +228,35 @@ const Header = () => {
)}
</div>
)}

{headlessState !== "closed" && (
<div
className={
headlessState === "open"
? "fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 p-4"
: "hidden"
}
onClick={() => setHeadlessState("closed")}
>
<div
className="relative w-full max-w-xl"
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={() => setHeadlessState("closed")}
className="absolute -top-3 -right-3 rounded-full bg-white px-3 py-1 text-sm font-medium text-gray-700 shadow hover:bg-gray-100"
>
Close
</button>
<HeadlessLogin
onStart={() => setHeadlessState("hidden")}
onDone={() => setHeadlessState("closed")}
onError={() => setHeadlessState("open")}
/>
</div>
</div>
)}
</div>
);
};
Expand Down
231 changes: 231 additions & 0 deletions examples/next/src/components/HeadlessLogin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
"use client";

import { useConnect } from "@starknet-react/core";
import { useState } from "react";
import { controllerConnector } from "./providers/StarknetProvider";

type AuthMethod = "passkey" | "metamask";

interface EthereumProvider {
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
isMetaMask?: boolean;
}

export function HeadlessLogin({
onStart,
onDone,
onError,
}: {
onStart?: () => void;
onDone?: () => void;
onError?: () => void;
}) {
const { connectAsync } = useConnect();
const [username, setUsername] = useState("");
const [loading, setLoading] = useState<AuthMethod | null>(null);
const [result, setResult] = useState<{
success: boolean;
message: string;
address?: string;
} | null>(null);
const prepareHeadlessConnect = async () => {
const controller = controllerConnector.controller;
// Ensure we don't short-circuit on an existing account.
if (controller.account) {
await controller.disconnect();
}
return controller;
};

const handlePasskeyLogin = async () => {
if (!username) {
setResult({
success: false,
message: "Please provide a username",
});
return;
}

onStart?.();
setLoading("passkey");
setResult(null);

try {
const controller = await prepareHeadlessConnect();
const account = await controller.connect({
username,
signer: "webauthn",
});
if (!account) {
throw new Error("Failed to connect");
}

// Sync starknet-react state so the header/app reflect the new connection.
await connectAsync({ connector: controllerConnector });

setResult({
success: true,
message: "Successfully authenticated with Passkey!",
address: account.address,
});
onDone?.();
} catch (error: unknown) {
setResult({
success: false,
message: (error as Error)?.message || "Passkey authentication failed",
});
onError?.();
} finally {
setLoading(null);
}
};

const handleMetaMaskLogin = async () => {
if (!username) {
setResult({
success: false,
message: "Please provide a username",
});
return;
}

onStart?.();
setLoading("metamask");
setResult(null);

try {
// Check if MetaMask is installed
const ethereum = (window as { ethereum?: EthereumProvider }).ethereum;
if (!ethereum) {
setResult({
success: false,
message: "MetaMask is not installed",
});
onError?.();
return;
}

const controller = await prepareHeadlessConnect();
const account = await controller.connect({
username,
signer: "metamask",
});
if (!account) {
throw new Error("Failed to connect");
}

// Sync starknet-react state so the header/app reflect the new connection.
await connectAsync({ connector: controllerConnector });

setResult({
success: true,
message: "Successfully authenticated with MetaMask!",
address: account.address,
});
onDone?.();
} catch (error: unknown) {
setResult({
success: false,
message: (error as Error)?.message || "MetaMask authentication failed",
});
onError?.();
} finally {
setLoading(null);
}
};

return (
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Headless Login
</h3>

<p className="text-sm text-gray-600 dark:text-gray-400">
Test programmatic authentication without UI. This demonstrates the
headless mode feature for automated authentication with Passkey or
MetaMask.
</p>

<div className="space-y-3">
<div>
<label
htmlFor="headless-username"
className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Username
</label>
<input
id="headless-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-500"
disabled={loading !== null}
/>
</div>

<div className="flex gap-3">
<button
onClick={handlePasskeyLogin}
disabled={loading !== null || !username}
className="flex-1 rounded-md bg-purple-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-purple-500 dark:hover:bg-purple-600"
>
{loading === "passkey" ? "Authenticating..." : "Login with Passkey"}
</button>

<button
onClick={handleMetaMaskLogin}
disabled={loading !== null || !username}
className="flex-1 rounded-md bg-orange-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-orange-500 dark:hover:bg-orange-600"
>
{loading === "metamask"
? "Authenticating..."
: "Login with MetaMask"}
</button>
</div>
</div>

{result && (
<div
className={`mt-4 rounded-md p-4 ${
result.success
? "bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-400"
: "bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-400"
}`}
>
<p className="text-sm font-medium">
{result.success ? "✓ Success" : "✗ Error"}
</p>
<p className="mt-1 text-sm">{result.message}</p>
{result.address && (
<p className="mt-2 break-all font-mono text-xs">
Address: {result.address}
</p>
)}
</div>
)}

<div className="mt-4 space-y-2 rounded-md bg-blue-50 p-4 dark:bg-blue-900/20">
<p className="text-xs text-blue-800 dark:text-blue-400">
<strong>Passkey Authentication:</strong> Uses the passkey signer
already registered for this username. Make sure the account has a
WebAuthn signer before testing.
</p>
<p className="text-xs text-blue-800 dark:text-blue-400">
<strong>MetaMask Authentication:</strong> Requires MetaMask browser
extension to be installed. Will prompt for account connection when
clicked.
</p>
<p className="text-xs text-blue-800 dark:text-blue-400">
<strong>Note:</strong> Headless mode auto-submits login. With no
policies, sign-in completes without session approval UI. If policies
are unverified or include approvals, Keychain will prompt for session
approval after authentication; verified policies auto-create the
session. Use <code>connect({"{ username, signer }"})</code> and pass{" "}
<code>password</code> when using the password signer.
</p>
</div>
</div>
);
}
13 changes: 8 additions & 5 deletions examples/next/src/components/Starterpack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useAccount, useNetwork } from "@starknet-react/core";
import ControllerConnector from "@cartridge/connector/controller";
import { Button, Input } from "@cartridge/ui";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { constants, num } from "starknet";

export const Starterpack = () => {
Expand All @@ -12,7 +12,7 @@ export const Starterpack = () => {

const controllerConnector = connector as unknown as ControllerConnector;

const getDefaultStarterpackIds = () => {
const getDefaultStarterpackIds = useCallback(() => {
if (chain && num.toHex(chain.id) === constants.StarknetChainId.SN_MAIN) {
return {
purchaseOnchain: 0,
Expand All @@ -23,9 +23,12 @@ export const Starterpack = () => {
purchaseOnchain: 0,
claim: "claim-dopewars-sepolia",
};
};
}, [chain]);

const defaultIds = getDefaultStarterpackIds();
const defaultIds = useMemo(
() => getDefaultStarterpackIds(),
[getDefaultStarterpackIds],
);
const [claimSpId, setClaimSpId] = useState<string>(defaultIds.claim);
const [claimPreimage, setClaimPreimage] = useState<string>("");
const [purchaseOnchainSpId, setPurchaseOnchainSpId] = useState<number>(
Expand Down Expand Up @@ -59,7 +62,7 @@ export const Starterpack = () => {
// Update our references after successful comparison and update
expectedDefaultsRef.current = newDefaults;
previousChainRef.current = chain;
}, [chain]);
}, [chain, getDefaultStarterpackIds]);

if (!account) {
return null;
Expand Down
Loading
Loading