@dexterai/connect 0.1.0 → 0.3.0

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/README.md CHANGED
@@ -1,16 +1,91 @@
1
- # @dexterai/connect
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/Dexter-DAO/dexter-x402-sdk/main/assets/dexter-wordmark.svg" alt="Dexter" width="360">
3
+ </p>
2
4
 
3
- **Sign in with Dexter** — a passkey connector. Tap your face, you're in.
5
+ <h1 align="center">@dexterai/connect</h1>
4
6
 
5
- Composes [`@dexterai/vault`](https://www.npmjs.com/package/@dexterai/vault). Two entry points:
7
+ <p align="center">
8
+ <strong>Sign in with Dexter — passkey sign-in for any app. Tap your face, you're in: a non-custodial Dexter Wallet and its live USD balance, in one component.</strong>
9
+ </p>
6
10
 
7
- - `@dexterai/connect` — framework-free relay client + types
8
- - `@dexterai/connect/react` — `<SignInWithDexter/>` + `useSignInWithDexter()`
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/@dexterai/connect"><img src="https://img.shields.io/npm/v/@dexterai/connect.svg" alt="npm"></a>
13
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/react-%3E=18-brightgreen.svg" alt="React"></a>
14
+ <a href="https://www.w3.org/TR/webauthn-2/"><img src="https://img.shields.io/badge/auth-passkey-00FF88" alt="Passkey"></a>
15
+ </p>
9
16
 
10
- Dependencies: `@dexterai/vault` + `react` (peer) **only**. No payment-layer peers — a
11
- sign-in button must not inherit a payment graph.
17
+ ---
12
18
 
13
- ## Status
19
+ ## What this is
14
20
 
15
- Scaffolding Milestone 1. Build plan + frozen contracts:
16
- `dexter-fe/docs/superpowers/plans/2026-06-20-signin-with-dexter-connect.md`.
21
+ A React connector that adds **"Sign in with Dexter"** to any app. One
22
+ `<SignInWithDexter/>` button runs a discoverable passkey ceremony — the user
23
+ taps their face and you get back a session plus their non-custodial **Dexter
24
+ Wallet** (address + live USD balance). No password, no seed phrase, no
25
+ extension.
26
+
27
+ The user holds their own keys. Nothing here is custodial — only the user's
28
+ passkey moves funds, enforced on-chain. Composes
29
+ [`@dexterai/vault`](https://www.npmjs.com/package/@dexterai/vault); the only
30
+ peer is React.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ npm install @dexterai/connect react
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```tsx
41
+ import { SignInWithDexter } from '@dexterai/connect/react';
42
+
43
+ function Header() {
44
+ return (
45
+ <SignInWithDexter
46
+ onSuccess={({ session, vault }) => {
47
+ // session = auth tokens (camelCase); vault = the Dexter Wallet
48
+ seatYourSession(session);
49
+ }}
50
+ />
51
+ );
52
+ }
53
+ ```
54
+
55
+ Signed out, it renders a **Sign in with Dexter** button. On success it becomes
56
+ a compact chip — the Dexter Wallet address + **"$X.XX available."**
57
+
58
+ ## Hook (full control)
59
+
60
+ For your own UI, use the hook directly:
61
+
62
+ ```tsx
63
+ import { useSignInWithDexter } from '@dexterai/connect/react';
64
+
65
+ const c = useSignInWithDexter();
66
+ await c.signIn(); // run the passkey ceremony
67
+ c.status; // idle → pending → done → error
68
+ c.vaultAddress; // the Dexter Wallet address (base58)
69
+ c.usdcBalance; // USD available (via Dexter's RPC), or null
70
+ c.disconnect();
71
+ ```
72
+
73
+ ## What `useSignInWithDexter()` gives you
74
+
75
+ | Field | What it is |
76
+ |---|---|
77
+ | `signIn()` / `disconnect()` | run the passkey ceremony / clear state |
78
+ | `status` / `isVaultConnected` | `idle→pending→done→error` / connected flag |
79
+ | `session` | auth session tokens (camelCase) |
80
+ | `vaultAddress` / `vaultPda` | the Dexter Wallet address / PDA |
81
+ | `usdcBalance` / `refreshBalance()` | USD available, best-effort via Dexter's RPC |
82
+ | `vault` / `credentialId` / `error` | raw vault payload / credential id / typed error |
83
+
84
+ ## Exports
85
+
86
+ - `@dexterai/connect` — framework-free: `passkeyLogin()`, `ConnectError`, types.
87
+ - `@dexterai/connect/react` — `<SignInWithDexter/>`, `useSignInWithDexter()`.
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,356 @@
1
+ // src/types.ts
2
+ var ConnectError = class extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message ?? code);
6
+ this.code = code;
7
+ this.name = "ConnectError";
8
+ }
9
+ };
10
+
11
+ // src/base64.ts
12
+ function base64ToBytes(s) {
13
+ const bin = atob(s);
14
+ const out = new Uint8Array(bin.length);
15
+ for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
16
+ return out;
17
+ }
18
+ function base64urlToBytes(s) {
19
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
20
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad;
21
+ return base64ToBytes(b64);
22
+ }
23
+ function bytesToBase64(bytes) {
24
+ let bin = "";
25
+ for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]);
26
+ return btoa(bin);
27
+ }
28
+ function bytesToBase64url(bytes) {
29
+ return bytesToBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
30
+ }
31
+ function compactSignatureToDer(compact) {
32
+ if (compact.length !== 64) {
33
+ throw new Error(`expected 64-byte compact signature, got ${compact.length}`);
34
+ }
35
+ const r = derInteger(compact.subarray(0, 32));
36
+ const s = derInteger(compact.subarray(32, 64));
37
+ const body = new Uint8Array(r.length + s.length);
38
+ body.set(r, 0);
39
+ body.set(s, r.length);
40
+ const out = new Uint8Array(2 + body.length);
41
+ out[0] = 48;
42
+ out[1] = body.length;
43
+ out.set(body, 2);
44
+ return out;
45
+ }
46
+ function derInteger(component) {
47
+ let start = 0;
48
+ while (start < component.length - 1 && component[start] === 0) start += 1;
49
+ let content = component.subarray(start);
50
+ if (content[0] & 128) {
51
+ const padded = new Uint8Array(content.length + 1);
52
+ padded[0] = 0;
53
+ padded.set(content, 1);
54
+ content = padded;
55
+ }
56
+ const out = new Uint8Array(2 + content.length);
57
+ out[0] = 2;
58
+ out[1] = content.length;
59
+ out.set(content, 2);
60
+ return out;
61
+ }
62
+
63
+ // src/relay.ts
64
+ var DEFAULT_API_BASE = "https://api.dexter.cash";
65
+ var ANON_SIGN_BASE = "/api/passkey-anon/sign";
66
+ async function passkeyLogin(config = {}) {
67
+ const apiBase = (config.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
68
+ const options = await fetchLoginChallenge(apiBase);
69
+ const credential = await getAssertion(options);
70
+ return submitLogin(apiBase, credential);
71
+ }
72
+ async function fetchLoginChallenge(apiBase) {
73
+ const res = await fetch(`${apiBase}${ANON_SIGN_BASE}/login-challenge`, {
74
+ method: "POST",
75
+ headers: { "content-type": "application/json" },
76
+ body: "{}"
77
+ });
78
+ if (!res.ok) {
79
+ throw new ConnectError("login_challenge_failed", `login-challenge ${res.status}`);
80
+ }
81
+ const data = await res.json();
82
+ if (!data?.options?.challenge) {
83
+ throw new ConnectError("login_challenge_malformed", "no challenge in response");
84
+ }
85
+ return data.options;
86
+ }
87
+ async function getAssertion(options) {
88
+ if (typeof navigator === "undefined" || !navigator.credentials) {
89
+ throw new ConnectError("webauthn_unsupported", "WebAuthn unavailable in this environment");
90
+ }
91
+ let credential;
92
+ try {
93
+ credential = await navigator.credentials.get({
94
+ publicKey: {
95
+ challenge: base64urlToBytes(options.challenge).buffer.slice(0),
96
+ rpId: options.rpId,
97
+ timeout: options.timeout ?? 6e4,
98
+ userVerification: options.userVerification ?? "required"
99
+ // No allowCredentials — discoverable resident-key login.
100
+ }
101
+ });
102
+ } catch (err) {
103
+ throw new ConnectError("webauthn_failed", err instanceof Error ? err.message : String(err));
104
+ }
105
+ if (!credential || credential.type !== "public-key") {
106
+ throw new ConnectError("no_credential", "WebAuthn returned no credential");
107
+ }
108
+ return credential;
109
+ }
110
+ async function submitLogin(apiBase, credential) {
111
+ const assertion = credential.response;
112
+ const credentialJson = {
113
+ id: credential.id,
114
+ rawId: bytesToBase64url(new Uint8Array(credential.rawId)),
115
+ type: credential.type,
116
+ response: {
117
+ clientDataJSON: bytesToBase64url(new Uint8Array(assertion.clientDataJSON)),
118
+ authenticatorData: bytesToBase64url(new Uint8Array(assertion.authenticatorData)),
119
+ signature: bytesToBase64url(new Uint8Array(assertion.signature)),
120
+ userHandle: assertion.userHandle ? bytesToBase64url(new Uint8Array(assertion.userHandle)) : null
121
+ },
122
+ clientExtensionResults: credential.getClientExtensionResults?.() ?? {}
123
+ };
124
+ const res = await fetch(`${apiBase}${ANON_SIGN_BASE}/passkey-login`, {
125
+ method: "POST",
126
+ headers: { "content-type": "application/json" },
127
+ body: JSON.stringify({ credential: credentialJson })
128
+ });
129
+ if (!res.ok) {
130
+ throw new ConnectError(await readErrorCode(res), `passkey-login ${res.status}`);
131
+ }
132
+ const data = await res.json();
133
+ const session = {
134
+ accessToken: data.accessToken,
135
+ refreshToken: data.refreshToken,
136
+ expiresAt: data.expiresAt,
137
+ expiresIn: data.expiresIn,
138
+ tokenType: data.tokenType
139
+ };
140
+ return data.vault ? { session, vault: data.vault } : { session };
141
+ }
142
+ async function readErrorCode(res) {
143
+ try {
144
+ const body = await res.json();
145
+ if (body?.error) return body.error;
146
+ } catch {
147
+ }
148
+ return `http_${res.status}`;
149
+ }
150
+
151
+ // src/anon-policy.ts
152
+ var DEFAULT_API_BASE2 = "https://api.dexter.cash";
153
+ var ANON_SIGN_BASE2 = "/api/passkey-anon/sign";
154
+ function createAnonServerPolicy(apiBase = DEFAULT_API_BASE2) {
155
+ const base = apiBase.replace(/\/$/, "");
156
+ return {
157
+ async issueChallenge({ userHandle, operationHash }) {
158
+ const res = await fetch(`${base}${ANON_SIGN_BASE2}/challenge`, {
159
+ method: "POST",
160
+ headers: { "content-type": "application/json" },
161
+ body: JSON.stringify({
162
+ userHandle: bytesToBase64url(userHandle),
163
+ operationHash: bytesToBase64url(operationHash)
164
+ })
165
+ });
166
+ if (!res.ok) throw new ConnectError(await readErrorCode2(res), `challenge ${res.status}`);
167
+ const data = await res.json();
168
+ const options = data?.options;
169
+ if (!options?.challenge) {
170
+ throw new ConnectError("challenge_malformed", "no challenge in response");
171
+ }
172
+ const cred = options.allowCredentials?.[0];
173
+ if (!cred?.id) {
174
+ throw new ConnectError("no_credential", "no allow-listed credential for this userHandle");
175
+ }
176
+ return {
177
+ challenge: base64urlToBytes(options.challenge),
178
+ credentialId: base64urlToBytes(cred.id),
179
+ rpId: options.rpId,
180
+ transports: cred.transports
181
+ };
182
+ },
183
+ async verify({ userHandle, credentialId, signature, clientDataJSON, authenticatorData }) {
184
+ const credential = {
185
+ id: bytesToBase64url(credentialId),
186
+ rawId: bytesToBase64url(credentialId),
187
+ type: "public-key",
188
+ response: {
189
+ clientDataJSON: bytesToBase64url(clientDataJSON),
190
+ authenticatorData: bytesToBase64url(authenticatorData),
191
+ // SDK 0.19 passes the COMPACT sig; dexter-api's verifier wants DER.
192
+ signature: bytesToBase64url(compactSignatureToDer(signature)),
193
+ userHandle: null
194
+ },
195
+ clientExtensionResults: {},
196
+ authenticatorAttachment: null
197
+ };
198
+ const res = await fetch(`${base}${ANON_SIGN_BASE2}/verify`, {
199
+ method: "POST",
200
+ headers: { "content-type": "application/json" },
201
+ body: JSON.stringify({ credential, userHandle: bytesToBase64url(userHandle) })
202
+ });
203
+ if (!res.ok) throw new ConnectError(await readErrorCode2(res), `verify ${res.status}`);
204
+ const data = await res.json();
205
+ if (data?.verified === false) {
206
+ throw new ConnectError("verification_failed", "server returned verified=false");
207
+ }
208
+ }
209
+ };
210
+ }
211
+ async function readErrorCode2(res) {
212
+ try {
213
+ const body = await res.json();
214
+ if (body?.error) return body.error;
215
+ } catch {
216
+ }
217
+ return `http_${res.status}`;
218
+ }
219
+
220
+ // src/signer.ts
221
+ import { DexterApiBrowserPasskeySigner } from "@dexterai/vault/signers/browser";
222
+ function createPasskeySigner(vault, apiBase, opts = {}) {
223
+ return new DexterApiBrowserPasskeySigner({
224
+ identity: { kind: "guest", userHandle: base64urlToBytes(vault.userHandle) },
225
+ publicKey: base64ToBytes(vault.publicKey),
226
+ anonPolicy: createAnonServerPolicy(apiBase),
227
+ ...opts.__assertion ? { __assertion: opts.__assertion } : {}
228
+ });
229
+ }
230
+
231
+ // src/walletStore.ts
232
+ var ACTIVE_HANDLE_KEY = "dexter:passkey:userHandle";
233
+ var ROSTER_KEY = "dexter:passkey:wallets";
234
+ var listeners = /* @__PURE__ */ new Set();
235
+ function hasStorage() {
236
+ try {
237
+ return typeof window !== "undefined" && !!window.localStorage;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+ function readRoster() {
243
+ if (!hasStorage()) return [];
244
+ try {
245
+ const raw = window.localStorage.getItem(ROSTER_KEY);
246
+ if (!raw) return [];
247
+ const parsed = JSON.parse(raw);
248
+ if (!Array.isArray(parsed)) return [];
249
+ return parsed.filter(
250
+ (w) => !!w && typeof w === "object" && typeof w.handle === "string"
251
+ );
252
+ } catch {
253
+ return [];
254
+ }
255
+ }
256
+ function writeRoster(wallets) {
257
+ if (!hasStorage()) return;
258
+ try {
259
+ window.localStorage.setItem(ROSTER_KEY, JSON.stringify(wallets));
260
+ } catch {
261
+ }
262
+ }
263
+ function emit() {
264
+ for (const l of listeners) {
265
+ try {
266
+ l();
267
+ } catch {
268
+ }
269
+ }
270
+ }
271
+ function getActiveHandle() {
272
+ if (!hasStorage()) return null;
273
+ try {
274
+ return window.localStorage.getItem(ACTIVE_HANDLE_KEY);
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+ function setActiveHandle(handle, label) {
280
+ if (!hasStorage() || !handle) return;
281
+ try {
282
+ window.localStorage.setItem(ACTIVE_HANDLE_KEY, handle);
283
+ } catch {
284
+ return;
285
+ }
286
+ const roster = readRoster();
287
+ const existing = roster.find((w) => w.handle === handle);
288
+ const now = Date.now();
289
+ if (existing) {
290
+ existing.lastUsedAt = now;
291
+ if (label !== void 0) existing.label = label;
292
+ } else {
293
+ roster.push({ handle, label, lastUsedAt: now });
294
+ }
295
+ writeRoster(roster);
296
+ emit();
297
+ }
298
+ function ejectActiveWallet(opts) {
299
+ if (!hasStorage()) return;
300
+ const current = getActiveHandle();
301
+ try {
302
+ window.localStorage.removeItem(ACTIVE_HANDLE_KEY);
303
+ } catch {
304
+ }
305
+ if (opts?.forget && current) {
306
+ writeRoster(readRoster().filter((w) => w.handle !== current));
307
+ }
308
+ emit();
309
+ }
310
+ function listWallets() {
311
+ return readRoster().sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0));
312
+ }
313
+ function switchWallet(handle) {
314
+ if (!readRoster().some((w) => w.handle === handle)) return false;
315
+ setActiveHandle(handle);
316
+ return true;
317
+ }
318
+ function forgetWallet(handle) {
319
+ if (getActiveHandle() === handle) {
320
+ ejectActiveWallet({ forget: true });
321
+ return;
322
+ }
323
+ writeRoster(readRoster().filter((w) => w.handle !== handle));
324
+ emit();
325
+ }
326
+ function subscribe(listener) {
327
+ listeners.add(listener);
328
+ if (hasStorage() && listeners.size === 1) {
329
+ window.addEventListener("storage", onStorageEvent);
330
+ }
331
+ return () => {
332
+ listeners.delete(listener);
333
+ if (hasStorage() && listeners.size === 0) {
334
+ window.removeEventListener("storage", onStorageEvent);
335
+ }
336
+ };
337
+ }
338
+ function onStorageEvent(e) {
339
+ if (e.key === ACTIVE_HANDLE_KEY || e.key === ROSTER_KEY || e.key === null) emit();
340
+ }
341
+ var ACTIVE_WALLET_STORAGE_KEY = ACTIVE_HANDLE_KEY;
342
+
343
+ export {
344
+ ConnectError,
345
+ passkeyLogin,
346
+ createAnonServerPolicy,
347
+ createPasskeySigner,
348
+ getActiveHandle,
349
+ setActiveHandle,
350
+ ejectActiveWallet,
351
+ listWallets,
352
+ switchWallet,
353
+ forgetWallet,
354
+ subscribe,
355
+ ACTIVE_WALLET_STORAGE_KEY
356
+ };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { D as DexterConnectConfig, S as SignInResult } from './types-DT3KPBOE.js';
2
- export { C as ConnectError, a as ConnectVault, P as PasskeyLoginTokens } from './types-DT3KPBOE.js';
1
+ import { D as DexterConnectConfig, S as SignInResult, C as ConnectVault } from './walletStore-CWXhXuzK.js';
2
+ export { A as ACTIVE_WALLET_STORAGE_KEY, a as ConnectError, P as PasskeyLoginTokens, b as StoredWallet, e as ejectActiveWallet, f as forgetWallet, g as getActiveHandle, l as listWallets, s as setActiveHandle, c as subscribeWallet, d as switchWallet } from './walletStore-CWXhXuzK.js';
3
+ import { DexterApiBrowserPasskeySigner } from '@dexterai/vault/signers/browser';
3
4
 
4
5
  /**
5
6
  * "Sign in with Dexter" — the discoverable-credential login ceremony.
@@ -16,4 +17,63 @@ export { C as ConnectError, a as ConnectVault, P as PasskeyLoginTokens } from '.
16
17
  */
17
18
  declare function passkeyLogin(config?: DexterConnectConfig): Promise<SignInResult>;
18
19
 
19
- export { DexterConnectConfig, SignInResult, passkeyLogin };
20
+ /** What `issueChallenge` returns to the SDK signer. */
21
+ interface AnonChallengeResult {
22
+ /** Server-issued WebAuthn challenge (=== the supplied operationHash). */
23
+ challenge: Uint8Array;
24
+ /** Credential id the server resolved from the userHandle (allowCredentials[0]). */
25
+ credentialId: Uint8Array;
26
+ rpId?: string;
27
+ transports?: AuthenticatorTransport[];
28
+ }
29
+ /** The policy the SDK's guest `DexterApiBrowserPasskeySigner` consumes. */
30
+ interface AnonServerPolicy {
31
+ issueChallenge(args: {
32
+ userHandle: Uint8Array;
33
+ operationHash: Uint8Array;
34
+ }): Promise<AnonChallengeResult>;
35
+ verify(args: {
36
+ userHandle: Uint8Array;
37
+ credentialId: Uint8Array;
38
+ signature: Uint8Array;
39
+ clientDataJSON: Uint8Array;
40
+ authenticatorData: Uint8Array;
41
+ }): Promise<void>;
42
+ }
43
+ /**
44
+ * Build the anon ServerPolicy for a given dexter-api base.
45
+ *
46
+ * `issueChallenge` → POST /challenge { userHandle, operationHash } (both base64url).
47
+ * The server uses operationHash AS the WebAuthn challenge (replay binding +
48
+ * the on-chain webauthn.rs law: clientDataJSON.challenge === sha256(op)) and
49
+ * resolves the credential into options.allowCredentials[0].
50
+ * `verify` → POST /verify { credential, userHandle }. NOTE: the SDK hands us the
51
+ * COMPACT 64-byte signature; dexter-api's WebAuthn verifier wants DER, so we
52
+ * re-encode compact → DER here (compactSignatureToDer).
53
+ */
54
+ declare function createAnonServerPolicy(apiBase?: string): AnonServerPolicy;
55
+
56
+ /** Test seam mirroring the SDK's injected-assertion shape (production omits it). */
57
+ type AssertionLike = {
58
+ credentialId: Uint8Array;
59
+ assertOver(challenge: Uint8Array): Promise<{
60
+ signature: Uint8Array;
61
+ clientDataJSON: Uint8Array;
62
+ authenticatorData: Uint8Array;
63
+ }>;
64
+ };
65
+ /**
66
+ * Construct the guest signer from a connected `ConnectVault`.
67
+ *
68
+ * `vault.publicKey` is base64 (33-byte SEC1 compressed P-256); `vault.userHandle`
69
+ * is base64url (server-minted). Both are decoded to the Uint8Arrays the SDK wants.
70
+ *
71
+ * @param vault the connected vault from useSignInWithDexter()
72
+ * @param apiBase dexter-api base (defaults to https://api.dexter.cash via the policy)
73
+ * @param opts.__assertion test-only injected assertion (skips real WebAuthn)
74
+ */
75
+ declare function createPasskeySigner(vault: ConnectVault, apiBase?: string, opts?: {
76
+ __assertion?: AssertionLike;
77
+ }): DexterApiBrowserPasskeySigner;
78
+
79
+ export { type AnonChallengeResult, type AnonServerPolicy, ConnectVault, DexterConnectConfig, SignInResult, createAnonServerPolicy, createPasskeySigner, passkeyLogin };
package/dist/index.js CHANGED
@@ -1,8 +1,28 @@
1
1
  import {
2
+ ACTIVE_WALLET_STORAGE_KEY,
2
3
  ConnectError,
3
- passkeyLogin
4
- } from "./chunk-65VT7AU2.js";
4
+ createAnonServerPolicy,
5
+ createPasskeySigner,
6
+ ejectActiveWallet,
7
+ forgetWallet,
8
+ getActiveHandle,
9
+ listWallets,
10
+ passkeyLogin,
11
+ setActiveHandle,
12
+ subscribe,
13
+ switchWallet
14
+ } from "./chunk-HFG2GUFX.js";
5
15
  export {
16
+ ACTIVE_WALLET_STORAGE_KEY,
6
17
  ConnectError,
7
- passkeyLogin
18
+ createAnonServerPolicy,
19
+ createPasskeySigner,
20
+ ejectActiveWallet,
21
+ forgetWallet,
22
+ getActiveHandle,
23
+ listWallets,
24
+ passkeyLogin,
25
+ setActiveHandle,
26
+ subscribe as subscribeWallet,
27
+ switchWallet
8
28
  };
package/dist/react.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { S as SignInResult, P as PasskeyLoginTokens, a as ConnectVault, C as ConnectError } from './types-DT3KPBOE.js';
1
+ import { DexterApiBrowserPasskeySigner } from '@dexterai/vault/signers/browser';
2
+ import { S as SignInResult, P as PasskeyLoginTokens, C as ConnectVault, a as ConnectError, b as StoredWallet } from './walletStore-CWXhXuzK.js';
2
3
  import { ReactElement } from 'react';
3
4
 
4
5
  type ConnectStatus = 'idle' | 'pending' | 'done' | 'error';
@@ -21,6 +22,9 @@ interface UseSignInWithDexter {
21
22
  vaultAddress: string | null;
22
23
  vaultPda: string | null;
23
24
  credentialId: string | null;
25
+ /** Guest passkey signer for authorizing spends / opening x402 tabs. null until
26
+ * a vault is connected. Drive it via `passkeySigner.signOperation(op)`. */
27
+ passkeySigner: DexterApiBrowserPasskeySigner | null;
24
28
  /** USD available. number once read; null = unknown → chip shows wallet only. */
25
29
  usdcBalance: number | null;
26
30
  refreshBalance: () => Promise<void>;
@@ -60,4 +64,25 @@ interface SignInWithDexterProps extends UseSignInWithDexterConfig {
60
64
  */
61
65
  declare function SignInWithDexter(props: SignInWithDexterProps): ReactElement | null;
62
66
 
63
- export { type ConnectStatus, SignInWithDexter, type SignInWithDexterProps, type UseSignInWithDexter, type UseSignInWithDexterConfig, useSignInWithDexter };
67
+ interface UseDexterWallet {
68
+ /** Active wallet handle, or null if this browser has no active wallet. */
69
+ activeHandle: string | null;
70
+ /** Known wallets on this browser, most-recently-used first. */
71
+ wallets: StoredWallet[];
72
+ /**
73
+ * Eject the active wallet — "switch / start fresh / sign out of this wallet".
74
+ * The browser is no longer bound to it; the next enroll/recover starts clean.
75
+ * Pass `{ forget: true }` to also drop it from the roster.
76
+ */
77
+ eject: (opts?: {
78
+ forget?: boolean;
79
+ }) => void;
80
+ /** Switch the active wallet to a known handle. No-op if unknown. */
81
+ switchTo: (handle: string) => boolean;
82
+ /** Record/activate a handle (after enroll or recover). Prefer this over
83
+ * hand-writing localStorage so the roster + subscribers stay correct. */
84
+ setActive: (handle: string, label?: string) => void;
85
+ }
86
+ declare function useDexterWallet(): UseDexterWallet;
87
+
88
+ export { type ConnectStatus, SignInWithDexter, type SignInWithDexterProps, type UseDexterWallet, type UseSignInWithDexter, type UseSignInWithDexterConfig, useDexterWallet, useSignInWithDexter };
package/dist/react.js CHANGED
@@ -1,10 +1,17 @@
1
1
  import {
2
2
  ConnectError,
3
- passkeyLogin
4
- } from "./chunk-65VT7AU2.js";
3
+ createPasskeySigner,
4
+ ejectActiveWallet,
5
+ getActiveHandle,
6
+ listWallets,
7
+ passkeyLogin,
8
+ setActiveHandle,
9
+ subscribe,
10
+ switchWallet
11
+ } from "./chunk-HFG2GUFX.js";
5
12
 
6
13
  // src/useSignInWithDexter.ts
7
- import { useCallback, useEffect, useState } from "react";
14
+ import { useCallback, useEffect, useMemo, useState } from "react";
8
15
 
9
16
  // src/balance.ts
10
17
  async function fetchUsdcBalance(rpcUrl, usdcAta) {
@@ -80,6 +87,10 @@ function useSignInWithDexter(config = {}) {
80
87
  setError(null);
81
88
  setStatus("idle");
82
89
  }, []);
90
+ const passkeySigner = useMemo(
91
+ () => vault ? createPasskeySigner(vault, apiBase) : null,
92
+ [vault, apiBase]
93
+ );
83
94
  useEffect(() => {
84
95
  void refreshBalance();
85
96
  }, [refreshBalance]);
@@ -93,6 +104,7 @@ function useSignInWithDexter(config = {}) {
93
104
  vaultAddress: vault?.swigAddress ?? null,
94
105
  vaultPda: vault?.vaultPda ?? null,
95
106
  credentialId: vault?.credentialId ?? null,
107
+ passkeySigner,
96
108
  usdcBalance,
97
109
  refreshBalance,
98
110
  error
@@ -192,7 +204,30 @@ var DISCONNECT = {
192
204
  lineHeight: 1,
193
205
  opacity: 0.6
194
206
  };
207
+
208
+ // src/useDexterWallet.ts
209
+ import { useCallback as useCallback2, useEffect as useEffect2, useState as useState2 } from "react";
210
+ function useDexterWallet() {
211
+ const [activeHandle, setHandle] = useState2(() => getActiveHandle());
212
+ const [wallets, setWallets] = useState2(() => listWallets());
213
+ useEffect2(() => {
214
+ const sync = () => {
215
+ setHandle(getActiveHandle());
216
+ setWallets(listWallets());
217
+ };
218
+ sync();
219
+ return subscribe(sync);
220
+ }, []);
221
+ return {
222
+ activeHandle,
223
+ wallets,
224
+ eject: useCallback2((opts) => ejectActiveWallet(opts), []),
225
+ switchTo: useCallback2((handle) => switchWallet(handle), []),
226
+ setActive: useCallback2((handle, label) => setActiveHandle(handle, label), [])
227
+ };
228
+ }
195
229
  export {
196
230
  SignInWithDexter,
231
+ useDexterWallet,
197
232
  useSignInWithDexter
198
233
  };
@@ -0,0 +1,92 @@
1
+ /** Supabase session tokens returned by dexter-api's passkey-login (camelCase). */
2
+ interface PasskeyLoginTokens {
3
+ accessToken: string;
4
+ refreshToken: string;
5
+ expiresAt: number;
6
+ expiresIn: number;
7
+ tokenType: string;
8
+ }
9
+ /**
10
+ * Vault identity, returned ALONGSIDE the session by passkey-login once
11
+ * vault-review ships the dexter-api change (ASK 1). Optional until then —
12
+ * the connector degrades to session-only. Consumers that open x402 tabs
13
+ * (dexter-agents) need `vaultPda` + `publicKey` to build a passkey signer.
14
+ */
15
+ interface ConnectVault {
16
+ vaultPda: string;
17
+ /** Swig state address, base58 — the user-facing Dexter Wallet address. */
18
+ swigAddress: string;
19
+ /** v2 swig wallet PDA (deposit address); null until the swig is deployed. */
20
+ receiveAddress: string | null;
21
+ /** Swig wallet's USDC ATA, base58 (for the connected-chip balance read);
22
+ * null until the swig is deployed. Server-resolved (off-curve-safe). */
23
+ usdcAta: string | null;
24
+ /** base64 33-byte SEC1 compressed P-256 authority pubkey (for the signer). */
25
+ publicKey: string;
26
+ userHandle: string;
27
+ credentialId: string;
28
+ }
29
+ /** Result of a completed "Sign in with Dexter" ceremony. */
30
+ interface SignInResult {
31
+ session: PasskeyLoginTokens;
32
+ /** Present once vault-review ships the vault-in-login change. */
33
+ vault?: ConnectVault;
34
+ }
35
+ interface DexterConnectConfig {
36
+ /** dexter-api base. Default https://api.dexter.cash. */
37
+ apiBase?: string;
38
+ }
39
+ /** Typed error whose `code` is the server's snake_case error string. */
40
+ declare class ConnectError extends Error {
41
+ readonly code: string;
42
+ constructor(code: string, message?: string);
43
+ }
44
+
45
+ /** A wallet this browser knows about. `handle` is the identity; the rest is UX. */
46
+ interface StoredWallet {
47
+ /** base64url 16-byte user handle — the vault identity. */
48
+ handle: string;
49
+ /** Human label for switch UIs (e.g. an email, or "Dexter Wallet"). */
50
+ label?: string;
51
+ /** Epoch ms of last activation — for ordering the switcher. */
52
+ lastUsedAt?: number;
53
+ }
54
+ type Listener = () => void;
55
+ /** The active wallet handle, or null if this browser has no active wallet. */
56
+ declare function getActiveHandle(): string | null;
57
+ /**
58
+ * Set the active wallet handle (e.g. after enroll or recover), upserting it into
59
+ * the roster with a fresh `lastUsedAt`. Idempotent. Fires subscribers.
60
+ */
61
+ declare function setActiveHandle(handle: string, label?: string): void;
62
+ /**
63
+ * EJECT — clear the active wallet so the browser is no longer bound to it. This
64
+ * is "switch / start fresh / sign out of this wallet". The wallet stays in the
65
+ * roster (so the user can switch back) unless `forget` is true. After eject,
66
+ * `getActiveHandle()` is null and the next enroll/recover starts clean. Fires
67
+ * subscribers. This is the function whose absence WAS the welded-wallet bug.
68
+ */
69
+ declare function ejectActiveWallet(opts?: {
70
+ forget?: boolean;
71
+ }): void;
72
+ /** Every wallet this browser knows about, most-recently-used first. */
73
+ declare function listWallets(): StoredWallet[];
74
+ /**
75
+ * Switch the active wallet to a handle ALREADY in the roster. Returns false (and
76
+ * does nothing) if the handle is unknown — switching is only ever to a wallet
77
+ * this browser has seen, never to an arbitrary string.
78
+ */
79
+ declare function switchWallet(handle: string): boolean;
80
+ /** Remove a wallet from the roster entirely; clears active if it was active. */
81
+ declare function forgetWallet(handle: string): void;
82
+ /**
83
+ * Subscribe to active-wallet/roster changes. Returns an unsubscribe fn. Also
84
+ * wires the cross-tab `storage` event once, so ejecting in one tab updates the
85
+ * others. The React hook (`useDexterWallet`) is a thin wrapper over this.
86
+ */
87
+ declare function subscribe(listener: Listener): () => void;
88
+ /** Exposed for consumers that must reference the canonical key (migrations,
89
+ * tests). Prefer the accessors above — do NOT read localStorage by hand. */
90
+ declare const ACTIVE_WALLET_STORAGE_KEY = "dexter:passkey:userHandle";
91
+
92
+ export { ACTIVE_WALLET_STORAGE_KEY as A, type ConnectVault as C, type DexterConnectConfig as D, type PasskeyLoginTokens as P, type SignInResult as S, ConnectError as a, type StoredWallet as b, subscribe as c, switchWallet as d, ejectActiveWallet as e, forgetWallet as f, getActiveHandle as g, listWallets as l, setActiveHandle as s };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dexterai/connect",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Sign in with Dexter — passkey connector. Composes @dexterai/vault.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,13 +21,12 @@
21
21
  "test": "vitest run",
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
- "dependencies": {
25
- "@dexterai/vault": "^0.14.1"
26
- },
27
24
  "peerDependencies": {
25
+ "@dexterai/vault": ">=0.19",
28
26
  "react": ">=18"
29
27
  },
30
28
  "devDependencies": {
29
+ "@dexterai/vault": "^0.19.0",
31
30
  "@types/react": "^19.1.12",
32
31
  "react": "^19.2.5",
33
32
  "tsup": "^8.5.0",
@@ -1,118 +0,0 @@
1
- // src/types.ts
2
- var ConnectError = class extends Error {
3
- code;
4
- constructor(code, message) {
5
- super(message ?? code);
6
- this.code = code;
7
- this.name = "ConnectError";
8
- }
9
- };
10
-
11
- // src/relay.ts
12
- var DEFAULT_API_BASE = "https://api.dexter.cash";
13
- var ANON_SIGN_BASE = "/api/passkey-anon/sign";
14
- async function passkeyLogin(config = {}) {
15
- const apiBase = (config.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
16
- const options = await fetchLoginChallenge(apiBase);
17
- const credential = await getAssertion(options);
18
- return submitLogin(apiBase, credential);
19
- }
20
- async function fetchLoginChallenge(apiBase) {
21
- const res = await fetch(`${apiBase}${ANON_SIGN_BASE}/login-challenge`, {
22
- method: "POST",
23
- headers: { "content-type": "application/json" },
24
- body: "{}"
25
- });
26
- if (!res.ok) {
27
- throw new ConnectError("login_challenge_failed", `login-challenge ${res.status}`);
28
- }
29
- const data = await res.json();
30
- if (!data?.options?.challenge) {
31
- throw new ConnectError("login_challenge_malformed", "no challenge in response");
32
- }
33
- return data.options;
34
- }
35
- async function getAssertion(options) {
36
- if (typeof navigator === "undefined" || !navigator.credentials) {
37
- throw new ConnectError("webauthn_unsupported", "WebAuthn unavailable in this environment");
38
- }
39
- let credential;
40
- try {
41
- credential = await navigator.credentials.get({
42
- publicKey: {
43
- challenge: base64urlToBytes(options.challenge).buffer.slice(0),
44
- rpId: options.rpId,
45
- timeout: options.timeout ?? 6e4,
46
- userVerification: options.userVerification ?? "required"
47
- // No allowCredentials — discoverable resident-key login.
48
- }
49
- });
50
- } catch (err) {
51
- throw new ConnectError("webauthn_failed", err instanceof Error ? err.message : String(err));
52
- }
53
- if (!credential || credential.type !== "public-key") {
54
- throw new ConnectError("no_credential", "WebAuthn returned no credential");
55
- }
56
- return credential;
57
- }
58
- async function submitLogin(apiBase, credential) {
59
- const assertion = credential.response;
60
- const credentialJson = {
61
- id: credential.id,
62
- rawId: bytesToBase64url(new Uint8Array(credential.rawId)),
63
- type: credential.type,
64
- response: {
65
- clientDataJSON: bytesToBase64url(new Uint8Array(assertion.clientDataJSON)),
66
- authenticatorData: bytesToBase64url(new Uint8Array(assertion.authenticatorData)),
67
- signature: bytesToBase64url(new Uint8Array(assertion.signature)),
68
- userHandle: assertion.userHandle ? bytesToBase64url(new Uint8Array(assertion.userHandle)) : null
69
- },
70
- clientExtensionResults: credential.getClientExtensionResults?.() ?? {}
71
- };
72
- const res = await fetch(`${apiBase}${ANON_SIGN_BASE}/passkey-login`, {
73
- method: "POST",
74
- headers: { "content-type": "application/json" },
75
- body: JSON.stringify({ credential: credentialJson })
76
- });
77
- if (!res.ok) {
78
- throw new ConnectError(await readErrorCode(res), `passkey-login ${res.status}`);
79
- }
80
- const data = await res.json();
81
- const session = {
82
- accessToken: data.accessToken,
83
- refreshToken: data.refreshToken,
84
- expiresAt: data.expiresAt,
85
- expiresIn: data.expiresIn,
86
- tokenType: data.tokenType
87
- };
88
- return data.vault ? { session, vault: data.vault } : { session };
89
- }
90
- async function readErrorCode(res) {
91
- try {
92
- const body = await res.json();
93
- if (body?.error) return body.error;
94
- } catch {
95
- }
96
- return `http_${res.status}`;
97
- }
98
- function base64ToBytes(s) {
99
- const bin = atob(s);
100
- const out = new Uint8Array(bin.length);
101
- for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
102
- return out;
103
- }
104
- function base64urlToBytes(s) {
105
- const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
106
- const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad;
107
- return base64ToBytes(b64);
108
- }
109
- function bytesToBase64url(bytes) {
110
- let bin = "";
111
- for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]);
112
- return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
113
- }
114
-
115
- export {
116
- ConnectError,
117
- passkeyLogin
118
- };
@@ -1,45 +0,0 @@
1
- /** Supabase session tokens returned by dexter-api's passkey-login (camelCase). */
2
- interface PasskeyLoginTokens {
3
- accessToken: string;
4
- refreshToken: string;
5
- expiresAt: number;
6
- expiresIn: number;
7
- tokenType: string;
8
- }
9
- /**
10
- * Vault identity, returned ALONGSIDE the session by passkey-login once
11
- * vault-review ships the dexter-api change (ASK 1). Optional until then —
12
- * the connector degrades to session-only. Consumers that open x402 tabs
13
- * (dexter-agents) need `vaultPda` + `publicKey` to build a passkey signer.
14
- */
15
- interface ConnectVault {
16
- vaultPda: string;
17
- /** Swig state address, base58 — the user-facing Dexter Wallet address. */
18
- swigAddress: string;
19
- /** v2 swig wallet PDA (deposit address); null until the swig is deployed. */
20
- receiveAddress: string | null;
21
- /** Swig wallet's USDC ATA, base58 (for the connected-chip balance read);
22
- * null until the swig is deployed. Server-resolved (off-curve-safe). */
23
- usdcAta: string | null;
24
- /** base64 33-byte SEC1 compressed P-256 authority pubkey (for the signer). */
25
- publicKey: string;
26
- userHandle: string;
27
- credentialId: string;
28
- }
29
- /** Result of a completed "Sign in with Dexter" ceremony. */
30
- interface SignInResult {
31
- session: PasskeyLoginTokens;
32
- /** Present once vault-review ships the vault-in-login change. */
33
- vault?: ConnectVault;
34
- }
35
- interface DexterConnectConfig {
36
- /** dexter-api base. Default https://api.dexter.cash. */
37
- apiBase?: string;
38
- }
39
- /** Typed error whose `code` is the server's snake_case error string. */
40
- declare class ConnectError extends Error {
41
- readonly code: string;
42
- constructor(code: string, message?: string);
43
- }
44
-
45
- export { ConnectError as C, type DexterConnectConfig as D, type PasskeyLoginTokens as P, type SignInResult as S, type ConnectVault as a };