@matanetwork/sovereign-id-react 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MATA Network
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # @matanetwork/sovereign-id-react
2
+
3
+ React adapter for [`@matanetwork/sovereign-id`](https://www.npmjs.com/package/@matanetwork/sovereign-id).
4
+ Drop in a `<SignInButton/>`, get a signed JWT carrying a verified
5
+ DID + the user's consented claims.
6
+
7
+ Lowers your integration from ~20 LOC + manual state plumbing to one
8
+ component or one hook.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @matanetwork/sovereign-id-react @matanetwork/sovereign-id react
14
+ ```
15
+
16
+ `react` is a peer dependency — works with React 17, 18, and 19.
17
+
18
+ For the backend verifier, also install [`@matanetwork/sovereign-id-verify`](https://www.npmjs.com/package/@matanetwork/sovereign-id-verify).
19
+
20
+ ## Quick start — drop-in button
21
+
22
+ ```jsx
23
+ import { SignInButton } from '@matanetwork/sovereign-id-react';
24
+
25
+ function LoginPage() {
26
+ return (
27
+ <SignInButton
28
+ getNonce={() => fetch('/api/auth/nonce').then((r) => r.text())}
29
+ claims={{ required: ['did'], optional: ['email', 'name'] }}
30
+ onSuccess={({ jwt }) =>
31
+ fetch('/api/auth/mid', {
32
+ method: 'POST',
33
+ headers: { 'Content-Type': 'application/json' },
34
+ body: JSON.stringify({ jwt }),
35
+ }).then(() => (window.location.href = '/dashboard'))
36
+ }
37
+ onCancel={(code) => console.log('user cancelled:', code)}
38
+ onError={(err) => console.error(err.code, err.message)}
39
+ />
40
+ );
41
+ }
42
+ ```
43
+
44
+ That's it. The button:
45
+
46
+ - Fetches a nonce from your backend
47
+ - Probes for the MATA extension → falls back to the native-app deep
48
+ link → falls back to the install upsell modal
49
+ - Resolves the user's consent decision
50
+ - Hands you `{ jwt, surface }` on success
51
+
52
+ ## Hook — custom button styling
53
+
54
+ When you want full control over the rendered button:
55
+
56
+ ```jsx
57
+ import { useSignIn } from '@matanetwork/sovereign-id-react';
58
+
59
+ function LoginPage() {
60
+ const { signIn, isLoading, error } = useSignIn({
61
+ rpOrigin: 'https://acme.com',
62
+ getNonce: () => fetch('/api/auth/nonce').then((r) => r.text()),
63
+ defaultClaims: { required: ['did'], optional: ['email'] },
64
+ });
65
+
66
+ return (
67
+ <>
68
+ <button
69
+ onClick={async () => {
70
+ try {
71
+ const { jwt } = await signIn();
72
+ await fetch('/api/auth/mid', {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({ jwt }),
76
+ });
77
+ window.location.href = '/dashboard';
78
+ } catch {
79
+ /* useSignIn already mirrors the error into `error` */
80
+ }
81
+ }}
82
+ disabled={isLoading}
83
+ className="my-styled-button"
84
+ >
85
+ {isLoading ? 'Signing in…' : 'Sign in with MATA'}
86
+ </button>
87
+ {error && <p role="alert">{error.message}</p>}
88
+ </>
89
+ );
90
+ }
91
+ ```
92
+
93
+ `useSignIn` returns `{ signIn, reset, status, isLoading, result, error }`.
94
+ Status is `'idle' | 'pending' | 'success' | 'error'`.
95
+
96
+ ## Resume after page reload
97
+
98
+ The install upsell flow stashes a pending sign-in in `sessionStorage`
99
+ so it survives a reload (common when Chrome prompts the user to
100
+ reload after extension install). Wire the resume at the root of your
101
+ app:
102
+
103
+ ```jsx
104
+ import { useResumePendingSignIn } from '@matanetwork/sovereign-id-react';
105
+
106
+ function App() {
107
+ useResumePendingSignIn({
108
+ onSuccess: ({ jwt }) => {
109
+ fetch('/api/auth/mid', {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({ jwt }),
113
+ }).then(() => (window.location.href = '/dashboard'));
114
+ },
115
+ });
116
+
117
+ return <Routes>{/* ... */}</Routes>;
118
+ }
119
+ ```
120
+
121
+ The hook fires once on mount, looks for a stashed pending sign-in,
122
+ and invokes `onSuccess` if one resumes. If you want to delay
123
+ rendering your logged-out UI until the resume check completes (avoids
124
+ a flicker), watch the `noResume` flag:
125
+
126
+ ```jsx
127
+ const { noResume, status } = useResumePendingSignIn({ onSuccess });
128
+ if (status === 'pending') return <Spinner />;
129
+ // noResume === true once we've confirmed nothing was pending
130
+ ```
131
+
132
+ ## API
133
+
134
+ ### `<SignInButton/>`
135
+
136
+ | Prop | Type | Required | Notes |
137
+ |---|---|---|---|
138
+ | `getNonce` | `() => Promise<string>` | yes | Async fn returning a fresh single-use nonce. |
139
+ | `claims` | `{ required, optional?, custom? }` | yes | Standard SDK claim shape. |
140
+ | `onSuccess` | `(result) => void` | yes | Called with `{ jwt, surface }`. |
141
+ | `onCancel` | `(code) => void` | no | Called on `user_denied` or `upsell_canceled`. |
142
+ | `onError` | `(err: SignInError) => void` | no | Called on any other failure path. |
143
+ | `rpOrigin` | `string` | no | Default: `window.location.origin`. |
144
+ | `installUpsell` | `boolean` | no | Default: SDK default (`true`). |
145
+ | `ref` | `string \| null` | no | Referral code. Default: hostname of `rpOrigin`. |
146
+ | `timeoutMs` | `number` | no | Default: 120000. |
147
+ | `nativeAppCallback` | `string` | no | Default: `window.location.href`. |
148
+ | `children` | `ReactNode` | no | Button label. Default: `"Sign in with Sovereign ID"`. |
149
+ | `className` | `string` | no | When set, default inline styles are NOT applied. |
150
+ | `style` | `CSSProperties` | no | Merged with default inline styles when `className` is unset. |
151
+ | `disabled` | `boolean` | no | OR'd with the internal in-flight disabled state. |
152
+ | `buttonProps` | `object` | no | Spread onto the `<button>` (aria-*, data-*, etc.). |
153
+
154
+ ### `useSignIn(options?)`
155
+
156
+ | Option | Type | Notes |
157
+ |---|---|---|
158
+ | `rpOrigin` | `string` | Default: `window.location.origin`. |
159
+ | `getNonce` | `() => Promise<string>` | Required unless you pass `nonce` to every `signIn()` call directly. |
160
+ | `defaultClaims` | `claims` | Default `claims` payload. |
161
+ | `installUpsell`, `ref`, `timeoutMs`, `nativeAppCallback` | — | Forwarded to the SDK. |
162
+
163
+ Returns:
164
+
165
+ ```ts
166
+ {
167
+ signIn(overrides?): Promise<{ jwt, surface } | null>;
168
+ reset(): void;
169
+ status: 'idle' | 'pending' | 'success' | 'error';
170
+ isLoading: boolean;
171
+ result: { jwt, surface } | null;
172
+ error: SignInError | null;
173
+ }
174
+ ```
175
+
176
+ The hook protects against stale calls — if you click sign-in twice
177
+ in a row, only the most recent call's result commits to state.
178
+
179
+ ### `useResumePendingSignIn(options?)`
180
+
181
+ | Option | Type | Notes |
182
+ |---|---|---|
183
+ | `onSuccess` | `(result) => void` | Called when a resume completes with a JWT. |
184
+ | `onError` | `(err) => void` | Called when a resume threw. |
185
+ | `onNothingPending` | `() => void` | Called when there's nothing to resume — most boots. |
186
+
187
+ Returns:
188
+
189
+ ```ts
190
+ {
191
+ status: 'idle' | 'pending' | 'success' | 'error';
192
+ result: { jwt, surface } | null;
193
+ error: SignInError | null;
194
+ noResume: boolean;
195
+ }
196
+ ```
197
+
198
+ ### Re-exports from the core SDK
199
+
200
+ For convenience, this package re-exports everything from
201
+ `@matanetwork/sovereign-id` so you only need one import line:
202
+
203
+ ```js
204
+ import {
205
+ SignInError,
206
+ ERR_USER_DENIED,
207
+ ERR_UPSELL_CANCELED,
208
+ hasExtension,
209
+ defaultRefFromOrigin,
210
+ // ...
211
+ } from '@matanetwork/sovereign-id-react';
212
+ ```
213
+
214
+ ## Backend
215
+
216
+ Same as the core SDK — use [`@matanetwork/sovereign-id-verify`](https://www.npmjs.com/package/@matanetwork/sovereign-id-verify):
217
+
218
+ ```js
219
+ import { verifyResponse } from '@matanetwork/sovereign-id-verify';
220
+
221
+ const verified = await verifyResponse(req.body.jwt, {
222
+ expectedAudience: 'https://acme.com',
223
+ expectedNonce: sessionNonce,
224
+ nowUnixSecs: Math.floor(Date.now() / 1000),
225
+ });
226
+ ```
227
+
228
+ See the
229
+ [main docs](https://github.com/mata-network/mata/tree/main/docs/sovereign-id)
230
+ for the integration guide, error-handling matrix, and protocol spec.
231
+
232
+ ## What you don't have to think about
233
+
234
+ - Wallet detection (extension vs. native app vs. install upsell)
235
+ - Resume after page reload
236
+ - Referral attribution (your domain rides along to `my.mata.network/signup` by default)
237
+ - Stale-call state collisions
238
+ - React 18 strict-mode double-mount during boot resume
239
+
240
+ ## License
241
+
242
+ MIT — see [LICENSE](LICENSE).
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@matanetwork/sovereign-id-react",
3
+ "version": "0.1.0",
4
+ "description": "React adapter for @matanetwork/sovereign-id — drop-in <SignInButton/>, useSignIn() hook, useResumePendingSignIn() boot helper. Lowers Sovereign ID RP integration from ~20 LOC to one import.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "types": "src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "import": "./src/index.js"
12
+ }
13
+ },
14
+ "sideEffects": false,
15
+ "scripts": {
16
+ "test": "node --test tests/index.test.js",
17
+ "prepublishOnly": "node --test tests/index.test.js"
18
+ },
19
+ "files": [
20
+ "src/",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "keywords": [
25
+ "mata",
26
+ "matanetwork",
27
+ "sovereign-id",
28
+ "mid",
29
+ "react",
30
+ "react-hook",
31
+ "sso",
32
+ "identity",
33
+ "did",
34
+ "did-mata",
35
+ "permissionless-auth",
36
+ "self-sovereign"
37
+ ],
38
+ "license": "MIT",
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "homepage": "https://github.com/mata-network/mata/tree/main/packages/mata-sovereign-id-react#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/mata-network/mata/issues"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/mata-network/mata.git",
49
+ "directory": "packages/mata-sovereign-id-react"
50
+ },
51
+ "peerDependencies": {
52
+ "@matanetwork/sovereign-id": "^0.1.0",
53
+ "react": ">=17.0.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "react": {
57
+ "optional": false
58
+ },
59
+ "@matanetwork/sovereign-id": {
60
+ "optional": false
61
+ }
62
+ },
63
+ "devDependencies": {
64
+ "@matanetwork/sovereign-id": "^0.1.0",
65
+ "react": "^18.3.1"
66
+ }
67
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * `<SignInButton/>` — drop-in React button that runs the full
3
+ * Sovereign ID sign-in flow on click.
4
+ *
5
+ * RPs that just want a working button drop this in and pass an
6
+ * `onSuccess` callback that hands the JWT to their backend. Custom
7
+ * styling via `className` / `style` / `children`. RPs that need more
8
+ * control over the click handler should use `useSignIn()` directly.
9
+ *
10
+ * ## Rendering note (no JSX)
11
+ *
12
+ * Authored with `React.createElement` so this package can ship as
13
+ * pure ESM with no build step. Bundlers (Vite / Webpack / esbuild)
14
+ * still tree-shake, type-check, etc. — they just don't have to
15
+ * transpile JSX. Keeps the package single-file-readable and zero-
16
+ * configure.
17
+ *
18
+ * ## What clicking does
19
+ *
20
+ * 1. Sets internal `loading` state.
21
+ * 2. Resolves the nonce via `getNonce` (required prop).
22
+ * 3. Calls the SDK's `signIn({ rpOrigin, nonce, claims })`.
23
+ * 4. On success, calls `onSuccess({ jwt, surface })`.
24
+ * 5. On `user_denied` / `upsell_canceled`, calls `onCancel(code)`.
25
+ * 6. On any other failure, calls `onError(err)`.
26
+ * 7. Resets loading state and re-enables the button.
27
+ *
28
+ * The button is `disabled` for the duration of step 1-6 so duplicate
29
+ * clicks are no-ops.
30
+ */
31
+
32
+ import { createElement } from 'react';
33
+ import { SignInError } from '@matanetwork/sovereign-id';
34
+ import { useSignIn } from './useSignIn.js';
35
+
36
+ const DEFAULT_LABEL = 'Sign in with Sovereign ID';
37
+
38
+ const DEFAULT_BUTTON_STYLE = {
39
+ // Match the popup CSS palette so the button feels at-home next to
40
+ // the consent screen that opens after the click.
41
+ background: '#4b2ad5',
42
+ color: '#fff',
43
+ border: 'none',
44
+ borderRadius: '8px',
45
+ padding: '10px 18px',
46
+ font: 'inherit',
47
+ fontSize: '14px',
48
+ fontWeight: 600,
49
+ cursor: 'pointer',
50
+ display: 'inline-flex',
51
+ alignItems: 'center',
52
+ gap: '8px',
53
+ lineHeight: 1.3,
54
+ transition: 'background 0.12s ease',
55
+ };
56
+
57
+ const DEFAULT_BUTTON_STYLE_DISABLED = {
58
+ ...DEFAULT_BUTTON_STYLE,
59
+ opacity: 0.6,
60
+ cursor: 'not-allowed',
61
+ };
62
+
63
+ /**
64
+ * @param {object} props
65
+ * @param {string} [props.rpOrigin] - Defaults to `window.location.origin`.
66
+ * @param {() => Promise<string>} props.getNonce - Async fn returning a
67
+ * fresh single-use nonce from your backend. Required.
68
+ * @param {object} props.claims - Claim catalog. `{ required: [...],
69
+ * optional?: [...], custom?: {...} }`. Required.
70
+ * @param {(result: {jwt: string, surface: 'extension' | 'native_app'}) => void} props.onSuccess
71
+ * Called with the resulting JWT.
72
+ * @param {(code: 'user_denied' | 'upsell_canceled') => void} [props.onCancel]
73
+ * Called when the user explicitly chose not to sign in.
74
+ * @param {(err: SignInError) => void} [props.onError]
75
+ * Called for any other failure path (timeout, wallet_unavailable,
76
+ * etc.). If omitted, errors are silently swallowed — RPs should
77
+ * usually pass at least a logger here.
78
+ * @param {boolean} [props.installUpsell] - SDK option pass-through.
79
+ * @param {string | null} [props.ref] - SDK referral code; defaults
80
+ * to the hostname of `rpOrigin`.
81
+ * @param {number} [props.timeoutMs] - SDK option pass-through.
82
+ * @param {string} [props.nativeAppCallback] - SDK option pass-through.
83
+ * @param {React.ReactNode} [props.children] - Button label. Defaults
84
+ * to `"Sign in with Sovereign ID"`.
85
+ * @param {string} [props.className] - When set, the default styles
86
+ * are NOT applied (assume the RP has full control via CSS).
87
+ * @param {React.CSSProperties} [props.style] - Merged with the
88
+ * default inline styles when `className` is not provided.
89
+ * @param {boolean} [props.disabled] - Explicitly disable the button.
90
+ * The button is also disabled internally while a sign-in is in
91
+ * flight; this prop is OR'd with that.
92
+ * @param {object} [props.buttonProps] - Spread onto the underlying
93
+ * `<button>` for things like `aria-*`, `data-*`, etc.
94
+ */
95
+ export function SignInButton(props) {
96
+ const {
97
+ rpOrigin,
98
+ getNonce,
99
+ claims,
100
+ onSuccess,
101
+ onCancel,
102
+ onError,
103
+ installUpsell,
104
+ ref,
105
+ timeoutMs,
106
+ nativeAppCallback,
107
+ children,
108
+ className,
109
+ style,
110
+ disabled = false,
111
+ buttonProps = {},
112
+ } = props;
113
+
114
+ const { signIn, isLoading } = useSignIn({
115
+ rpOrigin,
116
+ getNonce,
117
+ defaultClaims: claims,
118
+ installUpsell,
119
+ ref,
120
+ timeoutMs,
121
+ nativeAppCallback,
122
+ });
123
+
124
+ const isDisabled = disabled || isLoading;
125
+
126
+ const handleClick = async () => {
127
+ if (isDisabled) return;
128
+ try {
129
+ const result = await signIn();
130
+ if (result && typeof onSuccess === 'function') {
131
+ onSuccess(result);
132
+ }
133
+ } catch (err) {
134
+ if (err instanceof SignInError) {
135
+ if (
136
+ (err.code === 'user_denied' || err.code === 'upsell_canceled') &&
137
+ typeof onCancel === 'function'
138
+ ) {
139
+ onCancel(err.code);
140
+ return;
141
+ }
142
+ }
143
+ if (typeof onError === 'function') {
144
+ onError(err);
145
+ }
146
+ }
147
+ };
148
+
149
+ // When `className` is provided, defer fully to caller CSS — don't
150
+ // apply the default inline styles at all. When not, ship a sensible
151
+ // styled button and let `style` override fields one at a time.
152
+ const resolvedStyle = className
153
+ ? style
154
+ : { ...(isDisabled ? DEFAULT_BUTTON_STYLE_DISABLED : DEFAULT_BUTTON_STYLE), ...style };
155
+
156
+ return createElement(
157
+ 'button',
158
+ {
159
+ type: 'button',
160
+ onClick: handleClick,
161
+ disabled: isDisabled,
162
+ 'aria-busy': isLoading || undefined,
163
+ className,
164
+ style: resolvedStyle,
165
+ ...buttonProps,
166
+ },
167
+ children ?? DEFAULT_LABEL
168
+ );
169
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Type declarations for @matanetwork/sovereign-id-react.
3
+ *
4
+ * Mirrors the JS runtime exactly. The runtime is pure JS (no build
5
+ * step); this `.d.ts` exists so consumers using TypeScript get full
6
+ * type-checking + IDE intellisense.
7
+ */
8
+
9
+ import type { CSSProperties, ReactNode, ButtonHTMLAttributes } from 'react';
10
+ import type {
11
+ SignInError,
12
+ SignInRequest,
13
+ SignInSuccess,
14
+ ErrorCode,
15
+ } from '@matanetwork/sovereign-id';
16
+
17
+ // ─── Hook: useSignIn ───────────────────────────────────────────────────────
18
+
19
+ export interface UseSignInOptions {
20
+ /**
21
+ * Default `rpOrigin` used by `signIn()`. If omitted, falls back to
22
+ * `window.location.origin`. RPs running SSR should set this
23
+ * explicitly.
24
+ */
25
+ rpOrigin?: string;
26
+
27
+ /**
28
+ * Async function that returns a fresh single-use nonce. Called
29
+ * once per `signIn()` invocation; the resolved string is passed
30
+ * through to the SDK. If omitted, `signIn()` must be called with
31
+ * a `nonce` override directly.
32
+ */
33
+ getNonce?: () => Promise<string>;
34
+
35
+ /**
36
+ * Default `claims` payload, merged with the per-call override.
37
+ * Almost always `{ required: ['did'], optional: [...] }`.
38
+ */
39
+ defaultClaims?: SignInRequest['claims'];
40
+
41
+ /**
42
+ * Forwarded to the SDK. When `false`, throws
43
+ * `ERR_NO_WALLET_INSTALLED` raw instead of showing the install
44
+ * upsell. Default: SDK default (`true`).
45
+ */
46
+ installUpsell?: boolean;
47
+
48
+ /**
49
+ * Forwarded to the SDK. Referral code attributed to signups that
50
+ * flow through the install upsell. Defaults to the hostname of
51
+ * `rpOrigin`. Pass `null` to opt out.
52
+ */
53
+ ref?: string | null;
54
+
55
+ /** Forwarded to the SDK. */
56
+ timeoutMs?: number;
57
+
58
+ /** Forwarded to the SDK. */
59
+ nativeAppCallback?: string;
60
+ }
61
+
62
+ export type SignInStatus = 'idle' | 'pending' | 'success' | 'error';
63
+
64
+ export interface SignInOverrides {
65
+ rpOrigin?: string;
66
+ nonce?: string;
67
+ claims?: SignInRequest['claims'];
68
+ installUpsell?: boolean;
69
+ ref?: string | null;
70
+ timeoutMs?: number;
71
+ nativeAppCallback?: string;
72
+ }
73
+
74
+ export interface UseSignInReturn {
75
+ /**
76
+ * Fires a sign-in. Merges `overrides` over the hook-level
77
+ * options. Resolves to the result on success, `null` if the call
78
+ * was superseded by a newer signIn, or rejects with `SignInError`
79
+ * on failure.
80
+ */
81
+ signIn(overrides?: SignInOverrides): Promise<SignInSuccess | null>;
82
+ /** Drops result/error and returns to idle. */
83
+ reset(): void;
84
+ status: SignInStatus;
85
+ /** True iff `status === 'pending'`. */
86
+ isLoading: boolean;
87
+ result: SignInSuccess | null;
88
+ error: SignInError | null;
89
+ }
90
+
91
+ export function useSignIn(options?: UseSignInOptions): UseSignInReturn;
92
+
93
+ // ─── Hook: useResumePendingSignIn ──────────────────────────────────────────
94
+
95
+ export interface UseResumeOptions {
96
+ /**
97
+ * Called when a pending sign-in resumes and completes. RPs hand
98
+ * the JWT to their backend here.
99
+ */
100
+ onSuccess?: (result: SignInSuccess) => void;
101
+ /**
102
+ * Called when a pending sign-in was found but its resume threw.
103
+ * `err.code` is the same set the regular signIn() throws.
104
+ */
105
+ onError?: (err: SignInError) => void;
106
+ /**
107
+ * Called when there's no pending resume — most boots. Lets RPs
108
+ * record analytics on "did we hit the resume path on this boot"
109
+ * without watching the `noResume` flag.
110
+ */
111
+ onNothingPending?: () => void;
112
+ }
113
+
114
+ export interface UseResumeReturn {
115
+ status: SignInStatus;
116
+ result: SignInSuccess | null;
117
+ error: SignInError | null;
118
+ /**
119
+ * `true` once we've finished checking and confirmed no pending
120
+ * sign-in was waiting. Useful for RPs that want to delay
121
+ * rendering their logged-out UI until the resume check is done.
122
+ */
123
+ noResume: boolean;
124
+ }
125
+
126
+ export function useResumePendingSignIn(options?: UseResumeOptions): UseResumeReturn;
127
+
128
+ // ─── Component: SignInButton ───────────────────────────────────────────────
129
+
130
+ export interface SignInButtonProps {
131
+ /** Defaults to `window.location.origin`. */
132
+ rpOrigin?: string;
133
+ /** Async fn returning a fresh single-use nonce from your backend. */
134
+ getNonce: () => Promise<string>;
135
+ /** Claim catalog. `{ required: [...], optional?: [...] }`. */
136
+ claims: SignInRequest['claims'];
137
+ /** Called with the resulting JWT. */
138
+ onSuccess: (result: SignInSuccess) => void;
139
+ /**
140
+ * Called when the user explicitly chose not to sign in
141
+ * (`user_denied` from the consent screen, `upsell_canceled` from
142
+ * the install upsell modal).
143
+ */
144
+ onCancel?: (code: 'user_denied' | 'upsell_canceled') => void;
145
+ /**
146
+ * Called for any other failure path (timeout, wallet_unavailable,
147
+ * etc.). If omitted, errors are silently swallowed — RPs should
148
+ * usually pass at least a logger here.
149
+ */
150
+ onError?: (err: SignInError) => void;
151
+ /** SDK option pass-through. */
152
+ installUpsell?: boolean;
153
+ /** SDK referral code; defaults to the hostname of `rpOrigin`. */
154
+ ref?: string | null;
155
+ /** SDK option pass-through. */
156
+ timeoutMs?: number;
157
+ /** SDK option pass-through. */
158
+ nativeAppCallback?: string;
159
+ /** Button label. Defaults to `"Sign in with Sovereign ID"`. */
160
+ children?: ReactNode;
161
+ /**
162
+ * When set, the default inline styles are NOT applied; the RP has
163
+ * full control via CSS.
164
+ */
165
+ className?: string;
166
+ /**
167
+ * Merged with the default inline styles when `className` is not
168
+ * provided.
169
+ */
170
+ style?: CSSProperties;
171
+ /**
172
+ * Explicitly disable the button. The button is also disabled
173
+ * internally while a sign-in is in flight.
174
+ */
175
+ disabled?: boolean;
176
+ /** Spread onto the underlying `<button>`. */
177
+ buttonProps?: Omit<
178
+ ButtonHTMLAttributes<HTMLButtonElement>,
179
+ 'onClick' | 'disabled' | 'type' | 'style' | 'className' | 'children'
180
+ >;
181
+ }
182
+
183
+ export function SignInButton(props: SignInButtonProps): JSX.Element;
184
+
185
+ // ─── Re-exports from @matanetwork/sovereign-id ────────────────────────────
186
+
187
+ export {
188
+ SignInError,
189
+ hasExtension,
190
+ showInstallUpsell,
191
+ pickInstallCta,
192
+ defaultRefFromOrigin,
193
+ clearPendingSignIn,
194
+ resumePendingSignIn,
195
+ signIn,
196
+ PROTOCOL_VERSION,
197
+ WINDOW_MID_GLOBAL,
198
+ MESSAGE_DISCRIMINATOR,
199
+ KIND_SIGN_IN_REQUEST,
200
+ KIND_SIGN_IN_RESPONSE,
201
+ URL_SCHEME,
202
+ SCHEME_PATH_REQUEST,
203
+ QUERY_PARAM_PAYLOAD,
204
+ FRAGMENT_KEY_RESPONSE,
205
+ ERR_USER_DENIED,
206
+ ERR_ORIGIN_MISMATCH,
207
+ ERR_INVALID_REQUEST,
208
+ ERR_WALLET_UNAVAILABLE,
209
+ ERR_REQUIRED_CLAIM_UNAVAILABLE,
210
+ ERR_INTERNAL,
211
+ ERR_NO_WALLET_INSTALLED,
212
+ ERR_TIMEOUT,
213
+ ERR_UPSELL_CANCELED,
214
+ } from '@matanetwork/sovereign-id';
215
+
216
+ export type {
217
+ SignInRequest,
218
+ SignInSuccess,
219
+ ErrorCode,
220
+ CustomClaim,
221
+ InstallCta,
222
+ InstallUpsellOptions,
223
+ InstallUpsellResult,
224
+ SignInOptions,
225
+ } from '@matanetwork/sovereign-id';
package/src/index.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @matanetwork/sovereign-id-react — React adapter for the
3
+ * `@matanetwork/sovereign-id` SDK.
4
+ *
5
+ * Three entry points:
6
+ *
7
+ * - [`<SignInButton/>`](./SignInButton.js) — drop-in styled button.
8
+ * Three required props: `getNonce`, `claims`, `onSuccess`.
9
+ *
10
+ * - [`useSignIn`](./useSignIn.js) — hook for RPs that want their
11
+ * own button. Returns `{ signIn, status, isLoading, result, error,
12
+ * reset }`.
13
+ *
14
+ * - [`useResumePendingSignIn`](./useResumePendingSignIn.js) — boot-
15
+ * time hook. Call once at the root of your app to handle the
16
+ * resume-after-reload path that the install upsell sets up.
17
+ *
18
+ * Everything else (`SignInError`, error codes, `defaultRefFromOrigin`,
19
+ * etc.) is re-exported from `@matanetwork/sovereign-id` so RPs only
20
+ * need one import line.
21
+ */
22
+
23
+ export { SignInButton } from './SignInButton.js';
24
+ export { useSignIn } from './useSignIn.js';
25
+ export { useResumePendingSignIn } from './useResumePendingSignIn.js';
26
+
27
+ // Re-export the core SDK surface so RPs don't have to install both
28
+ // packages just to type their callbacks against `SignInError`.
29
+ export {
30
+ SignInError,
31
+ hasExtension,
32
+ showInstallUpsell,
33
+ pickInstallCta,
34
+ defaultRefFromOrigin,
35
+ clearPendingSignIn,
36
+ resumePendingSignIn,
37
+ signIn,
38
+ PROTOCOL_VERSION,
39
+ WINDOW_MID_GLOBAL,
40
+ MESSAGE_DISCRIMINATOR,
41
+ KIND_SIGN_IN_REQUEST,
42
+ KIND_SIGN_IN_RESPONSE,
43
+ URL_SCHEME,
44
+ SCHEME_PATH_REQUEST,
45
+ QUERY_PARAM_PAYLOAD,
46
+ FRAGMENT_KEY_RESPONSE,
47
+ ERR_USER_DENIED,
48
+ ERR_ORIGIN_MISMATCH,
49
+ ERR_INVALID_REQUEST,
50
+ ERR_WALLET_UNAVAILABLE,
51
+ ERR_REQUIRED_CLAIM_UNAVAILABLE,
52
+ ERR_INTERNAL,
53
+ ERR_NO_WALLET_INSTALLED,
54
+ ERR_TIMEOUT,
55
+ ERR_UPSELL_CANCELED,
56
+ } from '@matanetwork/sovereign-id';
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `useResumePendingSignIn` — React hook around
3
+ * `resumePendingSignIn()`.
4
+ *
5
+ * Call once at the root of your app. The hook fires
6
+ * `resumePendingSignIn()` on first mount, manages the resolution
7
+ * state, and invokes `onSuccess` / `onError` (if provided) when the
8
+ * resume completes.
9
+ *
10
+ * ## What it solves
11
+ *
12
+ * The install-upsell flow stashes a pending sign-in in
13
+ * `sessionStorage` so it survives a page reload (common when Chrome
14
+ * prompts the user to reload after extension install). On the next
15
+ * boot, the SDK's `resumePendingSignIn()` picks the flow back up. RPs
16
+ * that want this resume path need to call it explicitly — this hook
17
+ * makes that "drop it in App.jsx and forget" simple.
18
+ *
19
+ * ## Idempotent at boot
20
+ *
21
+ * Internally guards with a ref so React 18's strict-mode
22
+ * double-invoke doesn't fire two parallel resume calls. Whichever
23
+ * one wins commits; the other's result is dropped.
24
+ *
25
+ * ## Status reporting
26
+ *
27
+ * Exposes the same `status` shape as `useSignIn` so RPs that mount
28
+ * loading UI can share the rendering branch:
29
+ *
30
+ * - `idle` — hook hasn't started checking yet (one paint at most).
31
+ * - `pending` — `resumePendingSignIn()` is in flight.
32
+ * - `success` — a resume returned a JWT. Result is in `.result`.
33
+ * - `error` — a resume found a pending request and the SDK threw
34
+ * while completing it. Error is in `.error`.
35
+ * - `idle` (with `noResume: true`) — explicitly: no pending
36
+ * resume was found.
37
+ */
38
+
39
+ import { useEffect, useRef, useState } from 'react';
40
+ import {
41
+ resumePendingSignIn as sdkResume,
42
+ SignInError,
43
+ } from '@matanetwork/sovereign-id';
44
+
45
+ /**
46
+ * @typedef {object} UseResumeOptions
47
+ * @property {(result: {jwt: string, surface: 'extension' | 'native_app'}) => void} [onSuccess]
48
+ * Called when a pending sign-in resumes and completes. RPs hand
49
+ * the JWT to their backend here.
50
+ * @property {(err: SignInError) => void} [onError]
51
+ * Called when a pending sign-in was found but its resume threw.
52
+ * `err.code` is the same set the regular signIn() throws.
53
+ * @property {() => void} [onNothingPending]
54
+ * Called when there's no pending resume — most boots. Lets RPs
55
+ * telemetry "did we hit the resume path on this boot" without
56
+ * watching the `noResume` flag.
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} UseResumeReturn
61
+ * @property {'idle' | 'pending' | 'success' | 'error'} status
62
+ * @property {{jwt: string, surface: 'extension' | 'native_app'} | null} result
63
+ * @property {SignInError | null} error
64
+ * @property {boolean} noResume
65
+ * `true` once we've finished checking and confirmed no pending
66
+ * sign-in was waiting. Useful for RPs that want to delay
67
+ * rendering their logged-out UI until the resume check is done
68
+ * (avoids a flicker through the logged-out state when the
69
+ * resume succeeds).
70
+ */
71
+
72
+ /**
73
+ * @param {UseResumeOptions} [options]
74
+ * @returns {UseResumeReturn}
75
+ */
76
+ export function useResumePendingSignIn(options = {}) {
77
+ const [state, setState] = useState({
78
+ status: 'idle',
79
+ result: null,
80
+ error: null,
81
+ noResume: false,
82
+ });
83
+
84
+ // Guard against React 18 strict-mode double-mount. The first mount
85
+ // fires the resume; the dev-mode second mount is a no-op.
86
+ const hasStartedRef = useRef(false);
87
+
88
+ // Keep callbacks in a ref so the boot effect doesn't need to
89
+ // re-fire when the parent re-renders with new inline callbacks.
90
+ const optionsRef = useRef(options);
91
+ useEffect(() => {
92
+ optionsRef.current = options;
93
+ });
94
+
95
+ useEffect(() => {
96
+ if (hasStartedRef.current) return;
97
+ hasStartedRef.current = true;
98
+
99
+ let cancelled = false;
100
+ setState((s) => ({ ...s, status: 'pending' }));
101
+
102
+ (async () => {
103
+ try {
104
+ const result = await sdkResume();
105
+ if (cancelled) return;
106
+ if (result) {
107
+ setState({
108
+ status: 'success',
109
+ result,
110
+ error: null,
111
+ noResume: false,
112
+ });
113
+ optionsRef.current.onSuccess?.(result);
114
+ } else {
115
+ setState({
116
+ status: 'idle',
117
+ result: null,
118
+ error: null,
119
+ noResume: true,
120
+ });
121
+ optionsRef.current.onNothingPending?.();
122
+ }
123
+ } catch (e) {
124
+ if (cancelled) return;
125
+ const err =
126
+ e instanceof SignInError
127
+ ? e
128
+ : new SignInError(
129
+ 'internal_error',
130
+ `useResumePendingSignIn: unexpected error: ${e?.message ?? e}`
131
+ );
132
+ setState({
133
+ status: 'error',
134
+ result: null,
135
+ error: err,
136
+ noResume: false,
137
+ });
138
+ optionsRef.current.onError?.(err);
139
+ }
140
+ })();
141
+
142
+ return () => {
143
+ cancelled = true;
144
+ };
145
+ }, []);
146
+
147
+ return state;
148
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `useSignIn` — React hook around `@matanetwork/sovereign-id`'s `signIn`.
3
+ *
4
+ * Manages the full state machine of a sign-in attempt: idle → pending
5
+ * → (success | error). Exposes `signIn(extraRequest)` for the click
6
+ * handler, `reset()` for a clean retry, and the current status fields
7
+ * for rendering.
8
+ *
9
+ * Use this when you want full control over the button / link that
10
+ * triggers sign-in. If you just want a drop-in button, use
11
+ * `<SignInButton/>` from the same package.
12
+ *
13
+ * ## State machine
14
+ *
15
+ * idle ──signIn()──▶ pending ──┬──▶ success (result populated)
16
+ * └──▶ error (error populated)
17
+ *
18
+ * Any of those terminal states can transition back to `idle` via
19
+ * `reset()` or to `pending` again via a fresh `signIn()` call.
20
+ *
21
+ * ## Why a hook (not a one-shot helper)
22
+ *
23
+ * Sign-in is async, the UI needs to reflect three distinct phases
24
+ * (idle / pending / done), and the result drives downstream effects
25
+ * (call `/api/auth/mid`, set session, navigate). A hook is the
26
+ * idiomatic React shape for "stateful async operation."
27
+ *
28
+ * ## Stale-call protection
29
+ *
30
+ * If a component re-renders mid-flight or the user clicks the button
31
+ * twice in a row, only the **most recent** signIn() call resolves into
32
+ * state. Earlier calls' results are dropped (their promises still
33
+ * resolve, but their setStates are gated by a request-id check). This
34
+ * prevents a slow first call from clobbering a fast second call's
35
+ * result — common bug class for naive async hooks.
36
+ */
37
+
38
+ import { useCallback, useEffect, useRef, useState } from 'react';
39
+ import { signIn as sdkSignIn, SignInError } from '@matanetwork/sovereign-id';
40
+
41
+ /**
42
+ * @typedef {object} UseSignInOptions
43
+ * @property {string} [rpOrigin] - Default `rpOrigin` used by `signIn()`.
44
+ * If omitted, falls back to `window.location.origin`. RPs running
45
+ * SSR should set this explicitly.
46
+ * @property {() => Promise<string>} [getNonce] - Async function that
47
+ * returns a fresh single-use nonce. Called once per `signIn()`
48
+ * invocation; the resolved string is passed through to the SDK.
49
+ * If omitted, `signIn()` must be called with a `nonce` directly.
50
+ * @property {object} [defaultClaims] - Default `claims` payload, merged
51
+ * with the per-call override.
52
+ * @property {boolean} [installUpsell] - Forwarded to the SDK.
53
+ * @property {string | null} [ref] - Forwarded to the SDK. See the
54
+ * `defaultRefFromOrigin` docs for the auto-derive behavior.
55
+ * @property {number} [timeoutMs] - Forwarded to the SDK.
56
+ * @property {string} [nativeAppCallback] - Forwarded to the SDK.
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} UseSignInState
61
+ * @property {'idle' | 'pending' | 'success' | 'error'} status
62
+ * @property {boolean} isLoading - True iff `status === 'pending'`.
63
+ * @property {{jwt: string, surface: 'extension' | 'native_app'} | null} result
64
+ * @property {SignInError | null} error
65
+ */
66
+
67
+ /**
68
+ * @typedef {object} UseSignInReturn
69
+ * @property {(overrides?: object) => Promise<{jwt: string, surface: 'extension' | 'native_app'} | null>} signIn
70
+ * Fires a sign-in. Merges `overrides` over the hook-level options;
71
+ * `overrides.nonce` overrides `getNonce()`. Resolves to the result
72
+ * on success, `null` if the call was superseded by a newer signIn,
73
+ * or rejects with `SignInError` on failure.
74
+ * @property {() => void} reset - Drops result/error and returns to idle.
75
+ * @property {'idle' | 'pending' | 'success' | 'error'} status
76
+ * @property {boolean} isLoading
77
+ * @property {{jwt: string, surface: 'extension' | 'native_app'} | null} result
78
+ * @property {SignInError | null} error
79
+ */
80
+
81
+ /**
82
+ * @param {UseSignInOptions} [options]
83
+ * @returns {UseSignInReturn}
84
+ */
85
+ export function useSignIn(options = {}) {
86
+ const [state, setState] = useState({
87
+ status: 'idle',
88
+ result: null,
89
+ error: null,
90
+ });
91
+
92
+ // Stale-call protection: every signIn() bumps this counter; the
93
+ // resulting promise only commits its outcome to state if it matches
94
+ // the latest counter value at resolve time. Stops a slow first call
95
+ // from overwriting a fast second call.
96
+ const currentRequestIdRef = useRef(0);
97
+
98
+ // Mount-tracking so we don't `setState` after unmount (React 18
99
+ // tolerates it, but it still logs a warning). The ref is set by the
100
+ // cleanup function returned from useEffect.
101
+ const isMountedRef = useRef(true);
102
+ useEffect(
103
+ () => () => {
104
+ isMountedRef.current = false;
105
+ },
106
+ []
107
+ );
108
+
109
+ // Refs over options so the `signIn` callback doesn't have to be
110
+ // recreated every render when the caller passes inline objects.
111
+ // Stable reference → safe to use as a dep in the caller's own
112
+ // useEffect/useMemo.
113
+ const optionsRef = useRef(options);
114
+ useEffect(() => {
115
+ optionsRef.current = options;
116
+ });
117
+
118
+ const signIn = useCallback(async (overrides = {}) => {
119
+ const myRequestId = ++currentRequestIdRef.current;
120
+ setState({ status: 'pending', result: null, error: null });
121
+
122
+ const opts = optionsRef.current;
123
+
124
+ // Resolve nonce: caller override > hook-level getNonce().
125
+ let nonce = overrides.nonce;
126
+ if (typeof nonce !== 'string' || nonce.length === 0) {
127
+ if (typeof opts.getNonce !== 'function') {
128
+ const err = new SignInError(
129
+ 'invalid_request',
130
+ 'useSignIn: no nonce provided and no `getNonce` configured.'
131
+ );
132
+ // Commit only if still latest.
133
+ if (
134
+ currentRequestIdRef.current === myRequestId &&
135
+ isMountedRef.current
136
+ ) {
137
+ setState({ status: 'error', result: null, error: err });
138
+ }
139
+ throw err;
140
+ }
141
+ try {
142
+ nonce = await opts.getNonce();
143
+ } catch (e) {
144
+ const err =
145
+ e instanceof SignInError
146
+ ? e
147
+ : new SignInError(
148
+ 'invalid_request',
149
+ `useSignIn: getNonce() threw: ${e?.message ?? e}`
150
+ );
151
+ if (
152
+ currentRequestIdRef.current === myRequestId &&
153
+ isMountedRef.current
154
+ ) {
155
+ setState({ status: 'error', result: null, error: err });
156
+ }
157
+ throw err;
158
+ }
159
+ }
160
+
161
+ // Resolve rpOrigin.
162
+ const rpOrigin =
163
+ overrides.rpOrigin ??
164
+ opts.rpOrigin ??
165
+ (typeof window !== 'undefined' ? window.location.origin : undefined);
166
+
167
+ // Resolve claims. Caller-level overrides win field-by-field.
168
+ const claims = overrides.claims ??
169
+ opts.defaultClaims ?? { required: ['did'] };
170
+
171
+ const request = { rpOrigin, nonce, claims };
172
+
173
+ const sdkOpts = {
174
+ installUpsell:
175
+ overrides.installUpsell ?? opts.installUpsell ?? undefined,
176
+ ref: overrides.ref ?? opts.ref,
177
+ timeoutMs: overrides.timeoutMs ?? opts.timeoutMs ?? undefined,
178
+ nativeAppCallback:
179
+ overrides.nativeAppCallback ?? opts.nativeAppCallback ?? undefined,
180
+ };
181
+
182
+ try {
183
+ const result = await sdkSignIn(request, sdkOpts);
184
+ if (
185
+ currentRequestIdRef.current === myRequestId &&
186
+ isMountedRef.current
187
+ ) {
188
+ setState({ status: 'success', result, error: null });
189
+ }
190
+ return result;
191
+ } catch (e) {
192
+ const err =
193
+ e instanceof SignInError
194
+ ? e
195
+ : new SignInError(
196
+ 'internal_error',
197
+ `useSignIn: unexpected error: ${e?.message ?? e}`
198
+ );
199
+ if (
200
+ currentRequestIdRef.current === myRequestId &&
201
+ isMountedRef.current
202
+ ) {
203
+ setState({ status: 'error', result: null, error: err });
204
+ }
205
+ throw err;
206
+ }
207
+ }, []);
208
+
209
+ const reset = useCallback(() => {
210
+ currentRequestIdRef.current++;
211
+ setState({ status: 'idle', result: null, error: null });
212
+ }, []);
213
+
214
+ return {
215
+ signIn,
216
+ reset,
217
+ status: state.status,
218
+ isLoading: state.status === 'pending',
219
+ result: state.result,
220
+ error: state.error,
221
+ };
222
+ }