@dexterai/connect 0.1.0 → 0.2.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,236 @@
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
+ export {
232
+ ConnectError,
233
+ passkeyLogin,
234
+ createAnonServerPolicy,
235
+ createPasskeySigner
236
+ };
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 './types---RcNI2Y.js';
2
+ export { a as ConnectError, P as PasskeyLoginTokens } from './types---RcNI2Y.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,12 @@
1
1
  import {
2
2
  ConnectError,
3
+ createAnonServerPolicy,
4
+ createPasskeySigner,
3
5
  passkeyLogin
4
- } from "./chunk-65VT7AU2.js";
6
+ } from "./chunk-46P7XAAI.js";
5
7
  export {
6
8
  ConnectError,
9
+ createAnonServerPolicy,
10
+ createPasskeySigner,
7
11
  passkeyLogin
8
12
  };
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 } from './types---RcNI2Y.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>;
package/dist/react.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  ConnectError,
3
+ createPasskeySigner,
3
4
  passkeyLogin
4
- } from "./chunk-65VT7AU2.js";
5
+ } from "./chunk-46P7XAAI.js";
5
6
 
6
7
  // src/useSignInWithDexter.ts
7
- import { useCallback, useEffect, useState } from "react";
8
+ import { useCallback, useEffect, useMemo, useState } from "react";
8
9
 
9
10
  // src/balance.ts
10
11
  async function fetchUsdcBalance(rpcUrl, usdcAta) {
@@ -80,6 +81,10 @@ function useSignInWithDexter(config = {}) {
80
81
  setError(null);
81
82
  setStatus("idle");
82
83
  }, []);
84
+ const passkeySigner = useMemo(
85
+ () => vault ? createPasskeySigner(vault, apiBase) : null,
86
+ [vault, apiBase]
87
+ );
83
88
  useEffect(() => {
84
89
  void refreshBalance();
85
90
  }, [refreshBalance]);
@@ -93,6 +98,7 @@ function useSignInWithDexter(config = {}) {
93
98
  vaultAddress: vault?.swigAddress ?? null,
94
99
  vaultPda: vault?.vaultPda ?? null,
95
100
  credentialId: vault?.credentialId ?? null,
101
+ passkeySigner,
96
102
  usdcBalance,
97
103
  refreshBalance,
98
104
  error
@@ -42,4 +42,4 @@ declare class ConnectError extends Error {
42
42
  constructor(code: string, message?: string);
43
43
  }
44
44
 
45
- export { ConnectError as C, type DexterConnectConfig as D, type PasskeyLoginTokens as P, type SignInResult as S, type ConnectVault as a };
45
+ export { type ConnectVault as C, type DexterConnectConfig as D, type PasskeyLoginTokens as P, type SignInResult as S, ConnectError as a };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dexterai/connect",
3
- "version": "0.1.0",
3
+ "version": "0.2.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
- };