@synqauth/react-native 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aerosol
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 CHANGED
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
1
+ # @synqauth/react-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Synq for React Native: sign-in through the system browser, with tokens in the device's secure storage.
4
+
5
+ Synq is customer identity for businesses: an OpenID Connect issuer for each of your brands, with Solana wallet, social, email and passkey sign-in. The documentation is at [docs.synq.build](https://docs.synq.build).
6
+
7
+ This package is at 0.x and hasn't been published yet: its API can change in any release until Synq's first customer depends on it.
8
+
9
+ ## Usage
10
+
11
+ Register a native app with a redirect URI your app owns: an https link it claims (an iOS universal link, an Android App Link), or a scheme of its own, a reverse domain name such as `com.example.app:/callback`. Then:
12
+
13
+ ```tsx
14
+ import 'react-native-quick-crypto' // a WebCrypto polyfill: Hermes has none
15
+ import * as SecureStore from 'expo-secure-store'
16
+ import * as WebBrowser from 'expo-web-browser'
17
+ import { SynqNativeProvider, createSynqNative, useAuth, useSession } from '@synqauth/react-native'
18
+
19
+ const issuer = 'https://acme.auth.synq.build'
20
+ const synq = createSynqNative({
21
+ issuer,
22
+ clientId: 'app_…',
23
+ redirectUri: 'https://app.example.com/synq/callback',
24
+ webBrowser: WebBrowser,
25
+ secureStore: SecureStore,
26
+ })
27
+
28
+ // <SynqNativeProvider synq={synq} issuer={issuer}> around the app, then:
29
+ const { isSignedIn } = useSession()
30
+ const { signIn, signOut } = useAuth()
31
+ ```
32
+
33
+ Sign-in opens the system browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android), never an embedded web view. The session, refresh token included, stays in the device's secure storage.
34
+
35
+ To sign people in on the app's own screens instead (own-UI sign-in, on for the app in the dashboard), use `useSignIn()`: `password.signIn({ email, password })`, `emailCode.send(email)` and `emailCode.verify(code)`, and the rest. Passkeys, and anything else only the hosted page can do, go on in the system browser with `continueInBrowser()`.
36
+
37
+ ## License
38
+
39
+ MIT
@@ -0,0 +1,220 @@
1
+ import { AccountClient, AuthorizationUrlOptions, ChallengeMethod, ChallengeProblem, DeniedReason, IdTokenClaims, RedirectReason, Session, StartSignInOptions, SynqClient, SynqError, SynqStorage } from "@synqauth/js";
2
+ import { ReactNode } from "react";
3
+ //#region src/storage.d.ts
4
+ /** The part of `expo-secure-store` the SDK uses: pass the module itself. */
5
+ interface SecureStoreModule {
6
+ getItemAsync(key: string): Promise<string | null>;
7
+ setItemAsync(key: string, value: string): Promise<void>;
8
+ deleteItemAsync(key: string): Promise<void>;
9
+ }
10
+ /**
11
+ * The most one item holds: iOS's keychain takes more, but `expo-secure-store`
12
+ * warns past 2048 bytes, and a session's tokens are bigger than that.
13
+ */
14
+ export declare const CHUNK_SIZE = 1800;
15
+ /**
16
+ * The device's secure storage (the iOS keychain, Android's keystore) as a
17
+ * `SynqStorage`, through `expo-secure-store`: the session's tokens, the
18
+ * refresh token included, and a sign-in's transaction. A value too big for
19
+ * one item is split across several (`<key>.0`, `<key>.1`...), with the count
20
+ * under the key itself. One app is one process, so the lock is in memory.
21
+ */
22
+ export declare function secureStorage(store: SecureStoreModule): SynqStorage;
23
+ //#endregion
24
+ //#region src/client.d.ts
25
+ /** The part of `expo-web-browser` the SDK uses: pass the module itself. */
26
+ interface WebBrowserModule {
27
+ openAuthSessionAsync(url: string, redirectUrl: string): Promise<{
28
+ type: 'success';
29
+ url: string;
30
+ } | {
31
+ type: string;
32
+ }>;
33
+ }
34
+ /** What `createSynqNative` takes: the app at the brand, and the two Expo modules it runs on. */
35
+ interface SynqNativeOptions {
36
+ /** The brand's issuer URL. */
37
+ issuer: string;
38
+ /** The app's client id: a native app, registered with the redirect URI below. */
39
+ clientId: string;
40
+ /**
41
+ * Where Synq sends the browser back to, one of the app's registered
42
+ * redirect URIs: an https link the app claims (a universal link, an App
43
+ * Link) or a scheme of its own, a reverse domain name such as
44
+ * `com.example.app:/callback` (RFC 8252). Never plain http.
45
+ */
46
+ redirectUri: string;
47
+ /** Where the browser lands after signing out: one of the app's registered post-logout URIs. */
48
+ postLogoutRedirectUri?: string;
49
+ /** The scopes: `openid offline_access` by default, so the session lasts past its first access token. */
50
+ scope?: string;
51
+ /** The API the access token is for: a registered API's identifier. */
52
+ resource?: string;
53
+ /** `expo-web-browser`, which opens the system browser (ASWebAuthenticationSession, Custom Tabs). */
54
+ webBrowser: WebBrowserModule;
55
+ /** `expo-secure-store`, where the session and the refresh token are kept. */
56
+ secureStore: SecureStoreModule;
57
+ /** The `fetch` to call Synq with: the global one by default, `expo/fetch`'s included. */
58
+ fetch?: typeof fetch;
59
+ }
60
+ /**
61
+ * Synq in a React Native app: `@synqauth/js`'s client, signing in through
62
+ * the system browser (never an embedded web view, which can read what the
63
+ * person types) with PKCE and a callback on a link or scheme the app owns,
64
+ * and keeping the session in the device's secure storage.
65
+ */
66
+ interface SynqNative {
67
+ /** The underlying `@synqauth/js` client: tokens, refresh, events. */
68
+ readonly client: SynqClient;
69
+ /** The API the session's access token is for, as given. */
70
+ readonly resource: string | undefined;
71
+ /**
72
+ * Opens the brand's sign-in in the system browser and finishes it when the
73
+ * app's link comes back. Rejects with `cancelled` when the person closes it.
74
+ */
75
+ signIn(options?: AuthorizationUrlOptions): Promise<Session>;
76
+ /**
77
+ * Ends the session on the device and at Synq. With `browser`, also signs
78
+ * the person out of the brand's session in the system browser, which asks
79
+ * them first; without it, the browser's session at Synq stays.
80
+ */
81
+ signOut(options?: {
82
+ browser?: boolean;
83
+ }): Promise<void>;
84
+ }
85
+ /** Synq for a React Native app: the system browser for sign-in, the device's secure storage for the session. */
86
+ export declare function createSynqNative(options: SynqNativeOptions): SynqNative;
87
+ //#endregion
88
+ //#region src/provider.d.ts
89
+ /** A call's outcome: its data, or the `SynqError` it failed with. */
90
+ type SynqResult<T> = {
91
+ readonly data: T;
92
+ readonly error: null;
93
+ } | {
94
+ readonly data: null;
95
+ readonly error: SynqError;
96
+ };
97
+ /** What `SynqNativeProvider` takes. */
98
+ interface SynqNativeProviderProps {
99
+ /** The client `createSynqNative` made, once, outside any component. */
100
+ synq: SynqNative;
101
+ /** The brand's issuer, as given to `createSynqNative`: for the account API. */
102
+ issuer: string;
103
+ children?: ReactNode;
104
+ }
105
+ /**
106
+ * Gives the hooks the device's session: it reads it once from secure
107
+ * storage, then follows every sign-in, refresh and sign-out.
108
+ */
109
+ export declare function SynqNativeProvider({ synq, issuer, children }: SynqNativeProviderProps): import("react").JSX.Element;
110
+ /** What `useSession()` answers: whether the device's session is read yet, and who is signed in. */
111
+ interface UseSession {
112
+ readonly isLoaded: boolean;
113
+ readonly isSignedIn: boolean;
114
+ readonly session: Session | null;
115
+ }
116
+ /** The device's session. */
117
+ export declare function useSession(): UseSession;
118
+ /** What `useUser()` answers: the ID token's claims, and the brand's account API for the person. */
119
+ interface UseUser {
120
+ readonly isLoaded: boolean;
121
+ /** Who signed in, from the ID token; null when nobody is. */
122
+ readonly user: IdTokenClaims | null;
123
+ /**
124
+ * The brand's account API with the session's token, when the session's
125
+ * token is for it (`createSynqNative({ resource: accountApiUrl(issuer) })`).
126
+ */
127
+ readonly account: AccountClient | null;
128
+ }
129
+ /** Who is signed in, and their account. */
130
+ export declare function useUser(): UseUser;
131
+ /** What `useAuth()` answers: signing in and out, each answering `{ data, error }`. */
132
+ interface UseAuth {
133
+ readonly isLoaded: boolean;
134
+ readonly isSignedIn: boolean;
135
+ /** Opens the brand's sign-in in the system browser. A closed browser is the error `cancelled`. */
136
+ signIn(options?: AuthorizationUrlOptions): Promise<SynqResult<Session>>;
137
+ /** Ends the session; with `browser`, at the brand in the system browser too. */
138
+ signOut(options?: {
139
+ browser?: boolean;
140
+ }): Promise<SynqResult<null>>;
141
+ }
142
+ /** Signing in and out. */
143
+ export declare function useAuth(): UseAuth;
144
+ //#endregion
145
+ //#region src/sign-in.d.ts
146
+ /** Where a sign-in is: what the screen shows. */
147
+ type SignInStep = 'start' | 'code' | 'verify-email' | 'done';
148
+ /** What went wrong with the last step: branch on `code`, never on the English. */
149
+ interface SignInProblem {
150
+ /**
151
+ * Synq's problem with the step (`wrong_credentials`, `wrong_code`…), or
152
+ * the sign-in's own: `expired` (it timed out: start again), `refused`
153
+ * (this person can't sign in here: see `reason`), `unavailable` (Synq
154
+ * couldn't be reached, or failed).
155
+ */
156
+ readonly code: ChallengeProblem | 'expired' | 'refused' | 'unavailable';
157
+ /** Why, when `refused`. */
158
+ readonly reason?: DeniedReason;
159
+ /** Synq's words for it, in English: show your own for `code` in another language. */
160
+ readonly message: string;
161
+ /** Seconds to wait first, when Synq said. */
162
+ readonly retryAfter?: number;
163
+ }
164
+ /** What only the hosted page can do for this sign-in, and what to take there. */
165
+ interface SignInHandover {
166
+ readonly reason: RedirectReason;
167
+ readonly method?: ChallengeMethod;
168
+ readonly loginHint?: string;
169
+ }
170
+ /** What `useSignIn()` answers: the sign-in's step, what went wrong, and each method's steps. */
171
+ interface UseSignIn {
172
+ readonly step: SignInStep;
173
+ /** Whether a step is on its way to Synq. */
174
+ readonly pending: boolean;
175
+ readonly problem: SignInProblem | null;
176
+ /** The address a code went to. */
177
+ readonly email: string | undefined;
178
+ /** Whether a new account's address may be left unverified for now. */
179
+ readonly canSkip: boolean;
180
+ /** Set when only the hosted page can go on: `continueInBrowser()` goes there. */
181
+ readonly handover: SignInHandover | null;
182
+ readonly password: {
183
+ readonly signIn: (input: {
184
+ email: string;
185
+ password: string;
186
+ }) => Promise<void>;
187
+ readonly signUp: (input: {
188
+ email: string;
189
+ password: string;
190
+ }) => Promise<void>;
191
+ };
192
+ readonly emailCode: {
193
+ readonly send: (email: string) => Promise<void>;
194
+ readonly verify: (code: string) => Promise<void>;
195
+ readonly resend: () => Promise<void>;
196
+ };
197
+ readonly verifyEmail: {
198
+ readonly verify: (code: string) => Promise<void>;
199
+ readonly resend: () => Promise<void>;
200
+ readonly skip: () => Promise<void>;
201
+ };
202
+ /**
203
+ * Goes on at the hosted page in the system browser, with the method and
204
+ * address Synq named, or these: a passkey, a token gate, a step-up. A
205
+ * closed browser is the error `cancelled`.
206
+ */
207
+ readonly continueInBrowser: (options?: {
208
+ method?: string;
209
+ loginHint?: string;
210
+ }) => Promise<SynqResult<Session>>;
211
+ /** Drops the sign-in under way and goes back to the first step. */
212
+ readonly startOver: () => void;
213
+ }
214
+ /**
215
+ * One sign-in on the app's own screens. The provider follows the session
216
+ * it ends with, as after `useAuth().signIn()`.
217
+ */
218
+ export declare function useSignIn(options?: StartSignInOptions): UseSignIn;
219
+ //#endregion
220
+ export type { SecureStoreModule, SignInHandover, SignInProblem, SignInStep, SynqNative, SynqNativeOptions, SynqNativeProviderProps, SynqResult, UseAuth, UseSession, UseSignIn, UseUser, WebBrowserModule };
package/dist/index.js ADDED
@@ -0,0 +1,410 @@
1
+ import { SynqError, accountApiUrl, createAccountClient, createSynqClient, isSynqError } from "@synqauth/js";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/storage.ts
5
+ /**
6
+ * The most one item holds: iOS's keychain takes more, but `expo-secure-store`
7
+ * warns past 2048 bytes, and a session's tokens are bigger than that.
8
+ */
9
+ const CHUNK_SIZE = 1800;
10
+ /** A secure-store key: the store takes letters, digits, `.`, `-` and `_` only. */
11
+ function safeKey(key) {
12
+ return key.replace(/[^A-Za-z0-9._-]/g, "_");
13
+ }
14
+ /**
15
+ * The device's secure storage (the iOS keychain, Android's keystore) as a
16
+ * `SynqStorage`, through `expo-secure-store`: the session's tokens, the
17
+ * refresh token included, and a sign-in's transaction. A value too big for
18
+ * one item is split across several (`<key>.0`, `<key>.1`...), with the count
19
+ * under the key itself. One app is one process, so the lock is in memory.
20
+ */
21
+ function secureStorage(store) {
22
+ const locks = /* @__PURE__ */ new Map();
23
+ async function remove(key) {
24
+ const base = safeKey(key);
25
+ const head = await store.getItemAsync(base);
26
+ const count = head?.startsWith("chunks:") ? Number(head.slice(7)) : 0;
27
+ for (let i = 0; i < count; i++) await store.deleteItemAsync(`${base}.${i}`);
28
+ await store.deleteItemAsync(base);
29
+ }
30
+ return {
31
+ holdsRefreshTokens: true,
32
+ async get(key) {
33
+ const base = safeKey(key);
34
+ const head = await store.getItemAsync(base);
35
+ if (head === null || !head.startsWith("chunks:")) return head?.slice(6) ?? null;
36
+ const count = Number(head.slice(7));
37
+ const parts = [];
38
+ for (let i = 0; i < count; i++) {
39
+ const part = await store.getItemAsync(`${base}.${i}`);
40
+ if (part === null) return null;
41
+ parts.push(part);
42
+ }
43
+ return parts.join("");
44
+ },
45
+ async set(key, value) {
46
+ await remove(key);
47
+ const base = safeKey(key);
48
+ if (value.length <= 1800) {
49
+ await store.setItemAsync(base, `value:${value}`);
50
+ return;
51
+ }
52
+ const count = Math.ceil(value.length / CHUNK_SIZE);
53
+ for (let i = 0; i < count; i++) await store.setItemAsync(`${base}.${i}`, value.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE));
54
+ await store.setItemAsync(base, `chunks:${count}`);
55
+ },
56
+ remove,
57
+ lock(name, fn) {
58
+ const run = (locks.get(name) ?? Promise.resolve()).then(fn, fn);
59
+ const settled = run.then(() => void 0, () => void 0);
60
+ locks.set(name, settled);
61
+ settled.then(() => {
62
+ if (locks.get(name) === settled) locks.delete(name);
63
+ });
64
+ return run;
65
+ }
66
+ };
67
+ }
68
+ //#endregion
69
+ //#region src/client.ts
70
+ /** A WebCrypto the SDK needs for PKCE: Hermes has none, so the app installs one first. */
71
+ function assertWebCrypto() {
72
+ const crypto = globalThis.crypto;
73
+ if (typeof crypto?.subtle?.digest !== "function" || typeof crypto.getRandomValues !== "function") throw new SynqError("invalid_options", "@synqauth/react-native needs WebCrypto (crypto.subtle and getRandomValues): install a polyfill such as react-native-quick-crypto's install() before creating the client");
74
+ }
75
+ /**
76
+ * `fetch`, answering the global `Response`. Expo (SDK 52 on) replaces the
77
+ * global `fetch` with `expo/fetch`, whose responses are its own class: not
78
+ * React Native's `Response`, which has no `Symbol.toStringTag` either, so
79
+ * oauth4webapi refuses every one ("must be an instance of Response"). Such
80
+ * a response is read whole and handed on as a `Response`; Synq's answers
81
+ * are small JSON.
82
+ */
83
+ function standardResponses(fetcher) {
84
+ return async (input, init) => {
85
+ const response = await fetcher(input, init);
86
+ if (response instanceof Response) return response;
87
+ const status = response.status;
88
+ return new Response(status === 204 || status === 205 || status === 304 ? null : await response.arrayBuffer(), {
89
+ status,
90
+ statusText: response.statusText,
91
+ headers: response.headers
92
+ });
93
+ };
94
+ }
95
+ /** Synq for a React Native app: the system browser for sign-in, the device's secure storage for the session. */
96
+ function createSynqNative(options) {
97
+ assertWebCrypto();
98
+ if (/^http:/i.test(options.redirectUri)) throw new SynqError("invalid_options", "A mobile app's redirect URI is an https link it claims (a universal link or App Link) or a scheme of its own, not http");
99
+ const storage = secureStorage(options.secureStore);
100
+ const client = createSynqClient({
101
+ issuer: options.issuer,
102
+ clientId: options.clientId,
103
+ redirectUri: options.redirectUri,
104
+ postLogoutRedirectUri: options.postLogoutRedirectUri,
105
+ scope: options.scope ?? "openid offline_access",
106
+ resource: options.resource,
107
+ storage,
108
+ transactionStorage: storage,
109
+ fetch: standardResponses(options.fetch ?? ((input, init) => globalThis.fetch(input, init)))
110
+ });
111
+ return {
112
+ client,
113
+ resource: options.resource,
114
+ async signIn(signInOptions) {
115
+ const url = await client.authorizationUrl(signInOptions);
116
+ const result = await options.webBrowser.openAuthSessionAsync(url.href, options.redirectUri);
117
+ if (result.type !== "success" || !("url" in result)) throw new SynqError("cancelled", "The person closed the sign-in");
118
+ return client.handleCallback(result.url);
119
+ },
120
+ async signOut({ browser = false } = {}) {
121
+ const url = await client.signOut();
122
+ if (browser && url && options.postLogoutRedirectUri) await options.webBrowser.openAuthSessionAsync(url.href, options.postLogoutRedirectUri);
123
+ }
124
+ };
125
+ }
126
+ //#endregion
127
+ //#region src/provider.tsx
128
+ const SynqContext = createContext(null);
129
+ /**
130
+ * Gives the hooks the device's session: it reads it once from secure
131
+ * storage, then follows every sign-in, refresh and sign-out.
132
+ */
133
+ function SynqNativeProvider({ synq, issuer, children }) {
134
+ const [state, setState] = useState({
135
+ status: "loading",
136
+ session: null
137
+ });
138
+ useEffect(() => {
139
+ let live = true;
140
+ const unsubscribe = synq.client.subscribe((event) => {
141
+ if (!live) return;
142
+ setState(event.session ? {
143
+ status: "signed-in",
144
+ session: event.session
145
+ } : {
146
+ status: "signed-out",
147
+ session: null
148
+ });
149
+ });
150
+ synq.client.getSession().then((session) => {
151
+ if (!live) return;
152
+ setState((current) => current.status !== "loading" ? current : session ? {
153
+ status: "signed-in",
154
+ session
155
+ } : {
156
+ status: "signed-out",
157
+ session: null
158
+ });
159
+ }, () => {
160
+ if (live) setState({
161
+ status: "signed-out",
162
+ session: null
163
+ });
164
+ });
165
+ return () => {
166
+ live = false;
167
+ unsubscribe();
168
+ };
169
+ }, [synq]);
170
+ const value = useMemo(() => ({
171
+ synq,
172
+ state,
173
+ issuer
174
+ }), [
175
+ synq,
176
+ state,
177
+ issuer
178
+ ]);
179
+ return /* @__PURE__ */ jsx(SynqContext.Provider, {
180
+ value,
181
+ children
182
+ });
183
+ }
184
+ /** The provider's client and session, for this package's other hooks. */
185
+ function useSynq() {
186
+ const context = useContext(SynqContext);
187
+ if (!context) throw new Error("Synq hooks need a <SynqNativeProvider> above them");
188
+ return context;
189
+ }
190
+ /** The device's session. */
191
+ function useSession() {
192
+ const { state } = useSynq();
193
+ return {
194
+ isLoaded: state.status !== "loading",
195
+ isSignedIn: state.status === "signed-in",
196
+ session: state.session
197
+ };
198
+ }
199
+ /** Who is signed in, and their account. */
200
+ function useUser() {
201
+ const { synq, state, issuer } = useSynq();
202
+ const account = useMemo(() => state.session && synq.resource === accountApiUrl(issuer) ? createAccountClient({
203
+ issuer,
204
+ accessToken: () => synq.client.getAccessToken()
205
+ }) : null, [
206
+ synq,
207
+ state.session,
208
+ issuer
209
+ ]);
210
+ return {
211
+ isLoaded: state.status !== "loading",
212
+ user: state.session?.claims ?? null,
213
+ account
214
+ };
215
+ }
216
+ /** Signing in and out. */
217
+ function useAuth() {
218
+ const { synq, state } = useSynq();
219
+ const signIn = useCallback(async (options) => {
220
+ try {
221
+ return {
222
+ data: await synq.signIn(options),
223
+ error: null
224
+ };
225
+ } catch (error) {
226
+ return {
227
+ data: null,
228
+ error: asSynqError(error)
229
+ };
230
+ }
231
+ }, [synq]);
232
+ const signOut = useCallback(async (options) => {
233
+ try {
234
+ await synq.signOut(options);
235
+ return {
236
+ data: null,
237
+ error: null
238
+ };
239
+ } catch (error) {
240
+ return {
241
+ data: null,
242
+ error: asSynqError(error)
243
+ };
244
+ }
245
+ }, [synq]);
246
+ return {
247
+ isLoaded: state.status !== "loading",
248
+ isSignedIn: state.status === "signed-in",
249
+ signIn,
250
+ signOut
251
+ };
252
+ }
253
+ /** Whatever a call threw, as a `SynqError`. */
254
+ function asSynqError(error) {
255
+ return isSynqError(error) ? error : new SynqError("network_error", String(error), { cause: error });
256
+ }
257
+ //#endregion
258
+ //#region src/sign-in.ts
259
+ /**
260
+ * One sign-in on the app's own screens. The provider follows the session
261
+ * it ends with, as after `useAuth().signIn()`.
262
+ */
263
+ function useSignIn(options = {}) {
264
+ const { synq } = useSynq();
265
+ const auth = useAuth();
266
+ const flow = useRef(null);
267
+ const [step, setStep] = useState("start");
268
+ const [pending, setPending] = useState(0);
269
+ const [problem, setProblem] = useState(null);
270
+ const [email, setEmail] = useState(void 0);
271
+ const [canSkip, setCanSkip] = useState(false);
272
+ const [handover, setHandover] = useState(null);
273
+ const start = useRef(options);
274
+ useEffect(() => {
275
+ start.current = options;
276
+ });
277
+ const actions = useMemo(() => {
278
+ function show(result) {
279
+ switch (result.status) {
280
+ case "signed-in":
281
+ flow.current = null;
282
+ setStep("done");
283
+ setProblem(null);
284
+ return;
285
+ case "code-sent":
286
+ setStep("code");
287
+ setEmail(result.email);
288
+ setProblem(null);
289
+ return;
290
+ case "verify-email":
291
+ setStep("verify-email");
292
+ setEmail(result.email);
293
+ setCanSkip(result.canSkip);
294
+ setProblem(null);
295
+ return;
296
+ case "retry":
297
+ setProblem({
298
+ code: result.problem,
299
+ message: result.message,
300
+ ...result.retryAfter === void 0 ? {} : { retryAfter: result.retryAfter }
301
+ });
302
+ return;
303
+ case "hosted":
304
+ setHandover({
305
+ reason: result.reason,
306
+ ...result.method ? { method: result.method } : {},
307
+ ...result.loginHint ? { loginHint: result.loginHint } : {}
308
+ });
309
+ return;
310
+ case "passkey":
311
+ setHandover({
312
+ reason: "passkey_unavailable",
313
+ method: "passkey"
314
+ });
315
+ return;
316
+ case "refused":
317
+ flow.current = null;
318
+ setStep("start");
319
+ setProblem({
320
+ code: "refused",
321
+ reason: result.reason,
322
+ message: result.message
323
+ });
324
+ return;
325
+ }
326
+ }
327
+ async function run(ask) {
328
+ flow.current ??= synq.client.startSignIn(start.current);
329
+ const current = flow.current;
330
+ setPending((n) => n + 1);
331
+ try {
332
+ show(await ask(current));
333
+ } catch (thrown) {
334
+ const error = asSynqError(thrown);
335
+ if (error.code === "invalid_session") {
336
+ flow.current = null;
337
+ setStep("start");
338
+ setProblem({
339
+ code: "expired",
340
+ message: error.message
341
+ });
342
+ } else if (error.code === "too_many_requests") setProblem({
343
+ code: "limited",
344
+ message: error.message,
345
+ ...error.retryAfter === void 0 ? {} : { retryAfter: error.retryAfter }
346
+ });
347
+ else setProblem({
348
+ code: "unavailable",
349
+ message: error.message
350
+ });
351
+ } finally {
352
+ setPending((n) => n - 1);
353
+ }
354
+ }
355
+ return {
356
+ password: {
357
+ signIn: (input) => run((f) => f.password.signIn(input)),
358
+ signUp: (input) => run((f) => f.password.signUp(input))
359
+ },
360
+ emailCode: {
361
+ send: (address) => run((f) => f.email.send(address)),
362
+ verify: (code) => run((f) => f.email.verify(code)),
363
+ resend: () => run((f) => f.email.resend())
364
+ },
365
+ verifyEmail: {
366
+ verify: (code) => run((f) => f.verifyEmail.verify(code)),
367
+ resend: () => run((f) => f.verifyEmail.resend()),
368
+ skip: () => run((f) => f.verifyEmail.skip())
369
+ },
370
+ startOver: () => {
371
+ flow.current = null;
372
+ setStep("start");
373
+ setProblem(null);
374
+ setEmail(void 0);
375
+ setCanSkip(false);
376
+ setHandover(null);
377
+ }
378
+ };
379
+ }, [synq]);
380
+ const continueInBrowser = useCallback(async (overrides = {}) => {
381
+ const method = overrides.method ?? handover?.method;
382
+ const loginHint = overrides.loginHint ?? handover?.loginHint ?? email;
383
+ const result = await auth.signIn({
384
+ ...method ? { method } : {},
385
+ ...loginHint ? { loginHint } : {}
386
+ });
387
+ if (!result.error) {
388
+ flow.current = null;
389
+ setHandover(null);
390
+ setStep("done");
391
+ }
392
+ return result;
393
+ }, [
394
+ auth,
395
+ handover,
396
+ email
397
+ ]);
398
+ return {
399
+ step,
400
+ pending: pending > 0,
401
+ problem,
402
+ email,
403
+ canSkip,
404
+ handover,
405
+ ...actions,
406
+ continueInBrowser
407
+ };
408
+ }
409
+ //#endregion
410
+ export { CHUNK_SIZE, SynqNativeProvider, createSynqNative, secureStorage, useAuth, useSession, useSignIn, useUser };
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@synqauth/react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Synq for React Native: sign-in through the system browser, with tokens in the device's secure storage.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/aerosol-build/synq.git",
9
+ "directory": "packages/react-native"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "engines": {
14
+ "node": ">=22"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "dependencies": {
30
+ "@synqauth/js": "0.2.0"
31
+ },
32
+ "peerDependencies": {
33
+ "react": ">=19.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@testing-library/dom": "10.4.2",
37
+ "@testing-library/react": "16.3.3",
38
+ "@types/react": "19.3.0",
39
+ "@types/react-dom": "19.3.0",
40
+ "@vitest/coverage-v8": "5.0.2",
41
+ "jsdom": "30.1.1",
42
+ "react": "19.3.0",
43
+ "react-dom": "19.3.0",
44
+ "vitest": "5.0.2"
45
+ },
46
+ "scripts": {
47
+ "build": "tsdown",
48
+ "lint": "eslint .",
49
+ "typecheck": "tsc",
50
+ "test": "vitest run --coverage"
51
+ }
6
52
  }