@lunora/react-native 1.0.0-alpha.1 → 1.0.0-alpha.100

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
@@ -6,16 +6,19 @@
6
6
 
7
7
  </div>
8
8
 
9
+ > **Experimental** — this package is outside the Lunora 1.0 stability promise: its API may change in any release, without a major version bump.
10
+
9
11
  ---
10
12
 
11
13
  The same live hooks you use on the web — `useQuery`, `useMutation`,
12
14
  `useSubscription`, `useAuth`, `usePresence`, … — running on your phone, plus the
13
15
  two seams a native app needs that a browser gives you for free: a durable offline
14
- queue backed by `AsyncStorage`, and credentialed requests (there is no cookie jar
15
- in React Native, so the session has to be attached explicitly).
16
+ queue backed by `AsyncStorage`, and credentialed requests (the session is a
17
+ bearer, attached explicitly).
16
18
 
17
- This package **re-exports the entire `@lunora/react` surface**, so you import
18
- your hooks and provider from here, and adds:
19
+ This package **re-exports the whole `@lunora/react` surface** (see
20
+ [below](#re-exported-lunorareact-surface)), so you import your hooks and
21
+ provider from here, and adds:
19
22
 
20
23
  - `createLunoraClient(options)` — a `LunoraClient` factory tuned for React
21
24
  Native.
@@ -87,10 +90,9 @@ other clients write, and `send` is optimistic and offline-safe.
87
90
 
88
91
  ## Authentication (better-auth + Expo)
89
92
 
90
- React Native has no cookie jar, so the session is sent as a **bearer** token: the
91
- HTTP RPC carries it in the `Authorization` header and the live socket carries it
92
- in the `?token=` query param. A bearer avoids the `Cookie` header the runtime's
93
- CSRF guard rejects on an `Origin`-less native request (see [Why a bearer token](#why-a-bearer-token)).
93
+ The session is sent as a **bearer** token: the HTTP RPC carries it in the
94
+ `Authorization` header and the live socket carries it in the `?token=` query
95
+ param (see [Why a bearer token](#why-a-bearer-token)).
94
96
 
95
97
  ```tsx
96
98
  // auth.ts
@@ -129,10 +131,24 @@ import { client } from "./lunora";
129
131
  function Root() {
130
132
  const { data: session } = authClient.useSession();
131
133
 
134
+ // `expoBearerToken` is async since better-auth 1.7.1, and an async function
135
+ // is not a valid effect cleanup return — so kick off a promise instead.
136
+ // `cancelled` is load-bearing: two session changes in quick succession leave
137
+ // two reads in flight, and without it the slower one reinstates the previous
138
+ // session's token.
132
139
  useEffect(() => {
133
- const token = expoBearerToken(authClient);
134
- client.setAuthToken(token); // HTTP `Authorization: Bearer …`
135
- client.setWsToken(token ?? undefined); // WS `?token=…`
140
+ let cancelled = false;
141
+
142
+ void (async () => {
143
+ const token = await expoBearerToken(authClient);
144
+ if (cancelled) return;
145
+ client.setAuthToken(token); // HTTP `Authorization: Bearer …`
146
+ client.setWsToken(token ?? undefined); // WS `?token=…`
147
+ })();
148
+
149
+ return () => {
150
+ cancelled = true;
151
+ };
136
152
  }, [session]);
137
153
 
138
154
  // …render the app
@@ -165,11 +181,22 @@ import { expo } from "@better-auth/expo";
165
181
 
166
182
  Lunora's runtime enables a CSRF Origin-check by default — it rejects any
167
183
  state-changing HTTP request or WebSocket upgrade that carries a `Cookie` but no
168
- trusted `Origin`. React Native sends no `Origin`, so a cookie-based credential
169
- would be **rejected** once signed in. A bearer token carries no `Cookie`, so it's
170
- exempt — and it works identically on `react-native-web` (the browser lets you set
184
+ trusted `Origin`. React Native sends no `Origin`, so a cookie-based credential is
185
+ **rejected** once signed in. A bearer token is the credential instead, and it
186
+ works identically on `react-native-web` (the browser lets you set
171
187
  `Authorization`, and the token rides `?token=` on the socket).
172
188
 
189
+ Using a bearer does **not** by itself guarantee the absence of a `Cookie` header.
190
+ React Native has a real cookie jar — `fetch` is backed by the platform HTTP stack
191
+ (`NSURLSession` / `OkHttp`) and its shared cookie store — so the `Set-Cookie` a
192
+ better-auth sign-in returns is kept and re-attached to later requests, and the
193
+ guard then 403s every state-changing RPC with `FORBIDDEN_ORIGIN`. It is
194
+ intermittent (it depends on whether the jar holds the cookie that launch), and
195
+ `security.csrf.trustedOrigins` cannot fix it: the trust list is only consulted
196
+ for an `Origin` that was actually received, and a missing one is rejected
197
+ outright. `createLunoraClient` closes this by sending `credentials: "omit"` on
198
+ every request; if you pass your own `fetch`, wrap it in `withoutAmbientCookies`.
199
+
173
200
  ### TanStack Query focus / online managers
174
201
 
175
202
  React Native doesn't fire the browser's `focus` / `online` events, so Query
@@ -197,6 +224,23 @@ Everything on `LunoraClientOptions` (`url`, `wsUrl`, `authBasePath`,
197
224
  An explicit `persistence`, `fetch`, or `WebSocket` always takes precedence over
198
225
  the convenience derived from `storage` / `getAuthHeaders`.
199
226
 
227
+ ### Re-exported `@lunora/react` surface
228
+
229
+ Every hook, provider, and auth gate from [`@lunora/react`](../react) is
230
+ re-exported here, wholesale — a new hook there lands on native with no edit to
231
+ this package.
232
+
233
+ The payment kit is **not** part of that surface: `CheckoutButton`,
234
+ `CustomerPortalButton`, and `useCheckout` render a DOM `<button>` and navigate
235
+ via `globalThis.location`, so they ship from the `@lunora/react/payment` subpath
236
+ rather than the `@lunora/react` root, and nothing re-exports them here. Drive
237
+ purchases through the platform's own flow (or a WebView pointed at the web
238
+ checkout URL).
239
+
240
+ `useVoiceAgent` is re-exported but its defaults are Web APIs (`getUserMedia`,
241
+ Web Audio, `WebSocket`): on native, pass your own `createMicrophone` /
242
+ `createSpeaker` implementations.
243
+
200
244
  ### `@lunora/react-native/auth`
201
245
 
202
246
  - `expoBearerToken(authClient)` — reads the better-auth Expo session token (from
package/dist/auth.d.mts CHANGED
@@ -1,35 +1,51 @@
1
- export { expoClient, setupExpoFocusManager, setupExpoOnlineManager } from '@better-auth/expo/client';
1
+ export {
2
2
  /**
3
- * Read the current better-auth session token from an Expo auth client, for use
4
- * as a **bearer** credential on the Lunora client — `client.setAuthToken(token)`
5
- * for HTTP RPC and `client.setWsToken(token ?? undefined)` for the live socket.
6
- *
7
- * The Expo plugin persists the session as a cookie in `SecureStore`;
8
- * `getCookie()` exposes it, and this pulls the `session_token` value out.
9
- * better-auth's `bearer` plugin accepts that value verbatim in the
10
- * `Authorization` header — so the native client authenticates WITHOUT sending a
11
- * `Cookie`, which the runtime's CSRF guard rejects on an `Origin`-less native
12
- * request (React Native sends no `Origin`). Returns `null` when signed out.
13
- *
14
- * Re-run it whenever the session changes and feed the result to the client (see
15
- * the package README):
16
- *
17
- * ```ts
18
- * const token = expoBearerToken(authClient);
19
- * client.setAuthToken(token);
20
- * client.setWsToken(token ?? undefined);
21
- * ```
22
- */
23
- declare const expoBearerToken: (authClient: {
24
- getCookie: () => string;
25
- }) => null | string;
3
+ * The slice of a key/value store the better-auth Expo plugin needs. Pass Expo
4
+ * `SecureStore` straight in.
5
+ *
6
+ * Re-exported from `@better-auth/expo/client` rather than restated here. This
7
+ * used to be a hand-written `SecureStorageLike` mirroring what upstream needed
8
+ * — two synchronous methods — and better-auth 1.7 moved the Expo storage
9
+ * integration to asynchronous `SecureStore` methods, so the real shape is now
10
+ * `getItem` + `getItemAsync` + `setItem` + `setItemAsync`. A hand-written
11
+ * mirror does not fail when upstream widens like that; it just silently
12
+ * describes a store the plugin will not accept. Re-exporting makes the
13
+ * compiler track it, which is the same lesson the `getCookie` shim taught in
14
+ * this file's history.
15
+ * @experimental
16
+ */
17
+ type ExpoClientStorage, expoClient, setupExpoFocusManager, setupExpoOnlineManager } from '@better-auth/expo/client';
26
18
  /**
27
- * The slice of a key/value store the better-auth Expo plugin needs — the shape
28
- * of `expo-secure-store` (a synchronous `getItem`, plus `setItem`). Pass Expo
29
- * `SecureStore` straight in.
30
- */
31
- interface SecureStorageLike {
32
- getItem: (key: string) => null | string;
33
- setItem: (key: string, value: string) => unknown;
34
- }
35
- export { SecureStorageLike, expoBearerToken };
19
+ * Read the current better-auth session token from an Expo auth client, for use
20
+ * as a **bearer** credential on the Lunora client — `client.setAuthToken(token)`
21
+ * for HTTP RPC and `client.setWsToken(token ?? undefined)` for the live socket.
22
+ *
23
+ * The Expo plugin persists the session as a cookie in `SecureStore`;
24
+ * `getCookie()` exposes it, and this pulls the `session_token` value out.
25
+ * better-auth's `bearer` plugin accepts that value verbatim in the
26
+ * `Authorization` header — so the native client authenticates WITHOUT sending a
27
+ * `Cookie`, which the runtime's CSRF guard rejects on an `Origin`-less native
28
+ * request (React Native sends no `Origin`). Resolves `null` when signed out.
29
+ *
30
+ * **Async since better-auth 1.7.1**, which changed `@better-auth/expo`'s
31
+ * `getCookie` from `() => string` to `() => Promise<string>` (it reads
32
+ * `SecureStore` asynchronously now). The awaited value is what gets matched:
33
+ * regexing the Promise itself would test `"[object Promise]"`, find no
34
+ * `session_token`, and return `null` on every call — a signed-in native app
35
+ * that silently behaves as anonymous, which is exactly the failure this
36
+ * signature change prevents by making callers await.
37
+ *
38
+ * Re-run it whenever the session changes and feed the result to the client (see
39
+ * the package README):
40
+ *
41
+ * ```ts
42
+ * const token = await expoBearerToken(authClient);
43
+ * client.setAuthToken(token);
44
+ * client.setWsToken(token ?? undefined);
45
+ * ```
46
+ * @experimental
47
+ */
48
+ declare const expoBearerToken: (authClient: {
49
+ getCookie: () => Promise<string> | string;
50
+ }) => Promise<null | string>;
51
+ export { expoBearerToken };
package/dist/auth.d.ts CHANGED
@@ -1,35 +1,51 @@
1
- export { expoClient, setupExpoFocusManager, setupExpoOnlineManager } from '@better-auth/expo/client';
1
+ export {
2
2
  /**
3
- * Read the current better-auth session token from an Expo auth client, for use
4
- * as a **bearer** credential on the Lunora client — `client.setAuthToken(token)`
5
- * for HTTP RPC and `client.setWsToken(token ?? undefined)` for the live socket.
6
- *
7
- * The Expo plugin persists the session as a cookie in `SecureStore`;
8
- * `getCookie()` exposes it, and this pulls the `session_token` value out.
9
- * better-auth's `bearer` plugin accepts that value verbatim in the
10
- * `Authorization` header — so the native client authenticates WITHOUT sending a
11
- * `Cookie`, which the runtime's CSRF guard rejects on an `Origin`-less native
12
- * request (React Native sends no `Origin`). Returns `null` when signed out.
13
- *
14
- * Re-run it whenever the session changes and feed the result to the client (see
15
- * the package README):
16
- *
17
- * ```ts
18
- * const token = expoBearerToken(authClient);
19
- * client.setAuthToken(token);
20
- * client.setWsToken(token ?? undefined);
21
- * ```
22
- */
23
- declare const expoBearerToken: (authClient: {
24
- getCookie: () => string;
25
- }) => null | string;
3
+ * The slice of a key/value store the better-auth Expo plugin needs. Pass Expo
4
+ * `SecureStore` straight in.
5
+ *
6
+ * Re-exported from `@better-auth/expo/client` rather than restated here. This
7
+ * used to be a hand-written `SecureStorageLike` mirroring what upstream needed
8
+ * — two synchronous methods — and better-auth 1.7 moved the Expo storage
9
+ * integration to asynchronous `SecureStore` methods, so the real shape is now
10
+ * `getItem` + `getItemAsync` + `setItem` + `setItemAsync`. A hand-written
11
+ * mirror does not fail when upstream widens like that; it just silently
12
+ * describes a store the plugin will not accept. Re-exporting makes the
13
+ * compiler track it, which is the same lesson the `getCookie` shim taught in
14
+ * this file's history.
15
+ * @experimental
16
+ */
17
+ type ExpoClientStorage, expoClient, setupExpoFocusManager, setupExpoOnlineManager } from '@better-auth/expo/client';
26
18
  /**
27
- * The slice of a key/value store the better-auth Expo plugin needs — the shape
28
- * of `expo-secure-store` (a synchronous `getItem`, plus `setItem`). Pass Expo
29
- * `SecureStore` straight in.
30
- */
31
- interface SecureStorageLike {
32
- getItem: (key: string) => null | string;
33
- setItem: (key: string, value: string) => unknown;
34
- }
35
- export { SecureStorageLike, expoBearerToken };
19
+ * Read the current better-auth session token from an Expo auth client, for use
20
+ * as a **bearer** credential on the Lunora client — `client.setAuthToken(token)`
21
+ * for HTTP RPC and `client.setWsToken(token ?? undefined)` for the live socket.
22
+ *
23
+ * The Expo plugin persists the session as a cookie in `SecureStore`;
24
+ * `getCookie()` exposes it, and this pulls the `session_token` value out.
25
+ * better-auth's `bearer` plugin accepts that value verbatim in the
26
+ * `Authorization` header — so the native client authenticates WITHOUT sending a
27
+ * `Cookie`, which the runtime's CSRF guard rejects on an `Origin`-less native
28
+ * request (React Native sends no `Origin`). Resolves `null` when signed out.
29
+ *
30
+ * **Async since better-auth 1.7.1**, which changed `@better-auth/expo`'s
31
+ * `getCookie` from `() => string` to `() => Promise<string>` (it reads
32
+ * `SecureStore` asynchronously now). The awaited value is what gets matched:
33
+ * regexing the Promise itself would test `"[object Promise]"`, find no
34
+ * `session_token`, and return `null` on every call — a signed-in native app
35
+ * that silently behaves as anonymous, which is exactly the failure this
36
+ * signature change prevents by making callers await.
37
+ *
38
+ * Re-run it whenever the session changes and feed the result to the client (see
39
+ * the package README):
40
+ *
41
+ * ```ts
42
+ * const token = await expoBearerToken(authClient);
43
+ * client.setAuthToken(token);
44
+ * client.setWsToken(token ?? undefined);
45
+ * ```
46
+ * @experimental
47
+ */
48
+ declare const expoBearerToken: (authClient: {
49
+ getCookie: () => Promise<string> | string;
50
+ }) => Promise<null | string>;
51
+ export { expoBearerToken };
package/dist/auth.mjs CHANGED
@@ -1,2 +1 @@
1
- export { default as expoBearerToken } from './packem_shared/expoBearerToken-B0Zorz--.mjs';
2
- export { expoClient, setupExpoFocusManager, setupExpoOnlineManager } from '@better-auth/expo/client';
1
+ import{default as p}from"./packem_shared/expoBearerToken-CA88JtQp.mjs";import{expoClient as a,setupExpoFocusManager as n,setupExpoOnlineManager as t}from"@better-auth/expo/client";export{p as expoBearerToken,a as expoClient,n as setupExpoFocusManager,t as setupExpoOnlineManager};
package/dist/index.d.mts CHANGED
@@ -1,75 +1,140 @@
1
1
  import { AsyncStorageLike, LunoraClientOptions, LunoraClient } from '@lunora/client';
2
2
  export * from '@lunora/react';
3
3
  /**
4
- * A `() => headers` factory the React Native client threads onto every HTTP RPC
5
- * request *and* the WebSocket upgrade — a generic escape hatch for attaching a
6
- * **custom** credential header (an API-gateway key, a proxy token, …) that
7
- * React Native's missing cookie jar can't carry implicitly. Return `undefined`
8
- * (or an empty object) when there's nothing to attach.
9
- *
10
- * For better-auth Expo sessions, prefer a **bearer** token instead: read it with
11
- * `@lunora/react-native/auth`'s `expoBearerToken` and feed it to
12
- * `client.setAuthToken` / `setWsToken` (see the package README). A bearer avoids
13
- * the `Cookie` header the runtime's CSRF guard rejects on `Origin`-less native
14
- * requests.
15
- */
4
+ * A `() => headers` factory the React Native client threads onto every HTTP RPC
5
+ * request *and* the WebSocket upgrade — a generic escape hatch for attaching a
6
+ * **custom** credential header (an API-gateway key, a proxy token, …). Return
7
+ * `undefined` (or an empty object) when there's nothing to attach.
8
+ *
9
+ * For better-auth Expo sessions, prefer a **bearer** token instead: read it with
10
+ * `@lunora/react-native/auth`'s `expoBearerToken` and feed it to
11
+ * `client.setAuthToken` / `setWsToken` (see the package README). Either way the
12
+ * client sends `credentials: "omit"`, so the platform cookie jar never attaches a
13
+ * session cookie the runtime's CSRF guard would reject on an `Origin`-less native
14
+ * request.
15
+ * @experimental
16
+ */
16
17
  type AuthHeadersFactory = () => Record<string, string> | undefined;
17
18
  /**
18
- * Options for `createLunoraClient` — the React Native / Expo counterpart to
19
- * constructing a `LunoraClient` directly. It is `LunoraClientOptions` with two
20
- * React-Native-shaped conveniences layered on. `storage` wires the
21
- * AsyncStorage-backed offline-queue persistence for you (the browser default
22
- * auto-probes IndexedDB, which React Native lacks, so without this the queue
23
- * stays in memory and is lost on reload). `getAuthHeaders` is a generic escape
24
- * hatch for a custom credential header on both the HTTP RPC path and the
25
- * WebSocket upgrade; for better-auth sessions prefer a bearer token
26
- * (`expoBearerToken` + the client's `setAuthToken` / `setWsToken`).
27
- *
28
- * The `persistence`, `fetch`, and `WebSocket` fields of `LunoraClientOptions`
29
- * are still accepted and take precedence when you need full control; the two
30
- * conveniences are sugar over exactly those seams.
31
- */
19
+ * Options for `createLunoraClient` — the React Native / Expo counterpart to
20
+ * constructing a `LunoraClient` directly. It is `LunoraClientOptions` with two
21
+ * React-Native-shaped conveniences layered on. `storage` wires the
22
+ * AsyncStorage-backed offline-queue persistence for you (the browser default
23
+ * auto-probes IndexedDB, which React Native lacks, so without this the queue
24
+ * stays in memory and is lost on reload). `getAuthHeaders` is a generic escape
25
+ * hatch for a custom credential header on both the HTTP RPC path and the
26
+ * WebSocket upgrade; for better-auth sessions prefer a bearer token
27
+ * (`expoBearerToken` + the client's `setAuthToken` / `setWsToken`).
28
+ *
29
+ * The `persistence`, `fetch`, and `WebSocket` fields of `LunoraClientOptions`
30
+ * are still accepted and take precedence when you need full control; the two
31
+ * conveniences are sugar over exactly those seams.
32
+ * @experimental
33
+ */
32
34
  interface CreateLunoraClientOptions extends LunoraClientOptions {
33
35
  /**
34
- * Attaches a credential to every HTTP RPC request and to the WebSocket
35
- * upgrade request. See `AuthHeadersFactory`. When omitted, requests go out
36
- * with only whatever `LunoraClient` already sets (its bearer token from
37
- * `setAuthToken`, if any).
38
- *
39
- * Merged UNDER the client's own headers on the HTTP path (an explicit
40
- * `setAuthToken` bearer wins over a same-named header here); applied to the
41
- * WebSocket upgrade via a wrapping `WebSocket`, since the socket has no other
42
- * way to carry credentials in React Native.
43
- */
36
+ * Attaches a credential to every HTTP RPC request and to the WebSocket
37
+ * upgrade request. See `AuthHeadersFactory`. When omitted, requests go out
38
+ * with only whatever `LunoraClient` already sets (its bearer token from
39
+ * `setAuthToken`, if any).
40
+ *
41
+ * Merged UNDER the client's own headers on the HTTP path (an explicit
42
+ * `setAuthToken` bearer wins over a same-named header here); applied to the
43
+ * WebSocket upgrade via a wrapping `WebSocket`, since the socket has no other
44
+ * way to carry credentials in React Native.
45
+ */
44
46
  getAuthHeaders?: AuthHeadersFactory;
45
47
  /**
46
- * React Native `AsyncStorage` (or any async key/value store with the same
47
- * `getItem`/`setItem`/`removeItem` surface — Expo `SecureStore`, an in-memory
48
- * map in tests). When supplied, the offline mutation queue is persisted here
49
- * via `createAsyncStoragePersistence`, so writes made offline survive an app
50
- * restart. Ignored when an explicit `persistence` is passed.
51
- */
48
+ * React Native `AsyncStorage` (or any async key/value store with the same
49
+ * `getItem`/`setItem`/`removeItem` surface — Expo `SecureStore`, an in-memory
50
+ * map in tests). When supplied it backs two independent caches: the offline
51
+ * mutation queue (via `createAsyncStoragePersistence`, so writes made offline
52
+ * survive an app restart) and the durable query cache (via
53
+ * `createAsyncStorageQueryCache`, so reads repaint before the socket
54
+ * reconnects).
55
+ *
56
+ * Each is opted out of by its own option, not by the other: `persistence`
57
+ * overrides the queue and `queryCache` overrides the read cache, so
58
+ * `{ persistence: false, storage }` still writes query results to storage.
59
+ */
52
60
  storage?: AsyncStorageLike;
53
61
  }
54
62
  /**
55
- * Construct a `LunoraClient` tuned for React Native / Expo — a thin wrapper over
56
- * `new LunoraClient(options)` that fills in the two things a browser gets for
57
- * free but React Native does not.
58
- *
59
- * First, a durable offline queue: pass `storage` (React Native `AsyncStorage`,
60
- * or any async key/value store) and the offline mutation queue is persisted
61
- * through `createAsyncStoragePersistence` — the browser default auto-probes
62
- * IndexedDB, which React Native lacks, so without this the queue lives only in
63
- * memory and is lost on reload.
64
- *
65
- * Second, credentialed requests: pass `getAuthHeaders` and the returned headers
66
- * ride both the HTTP RPC path and the WebSocket upgrade, since React Native has
67
- * no cookie jar to attach a session implicitly.
68
- *
69
- * Everything on `LunoraClientOptions` is still accepted and passed through; an
70
- * explicit `persistence`, `fetch`, or `WebSocket` takes precedence over the
71
- * convenience derived from `storage` / `getAuthHeaders`. See the package README
72
- * for a full setup example.
73
- */
63
+ * Wrap a `fetch` so no request carries the platform's ambient cookie credential.
64
+ *
65
+ * React Native **does** have a cookie jar — its `fetch` is backed by the
66
+ * platform HTTP stack (`NSURLSession` / `OkHttp`), which owns a shared,
67
+ * persistent cookie store — so a `Set-Cookie` from a better-auth sign-in is kept
68
+ * and re-attached to later requests automatically. The package's own docs used
69
+ * to assert the opposite, and the bearer design leaned on it: "there is no jar,
70
+ * therefore no `Cookie` header, therefore the runtime's CSRF guard never sees
71
+ * one".
72
+ *
73
+ * It does see one. The guard rejects an unsafe, cookie-bearing request whose
74
+ * `Origin` is missing or untrusted, and a native request sends no `Origin` — so
75
+ * every state-changing RPC 403s with `FORBIDDEN_ORIGIN` for as long as the jar
76
+ * holds that cookie. `security.csrf.trustedOrigins` cannot fix it: the trust list
77
+ * is only consulted for an `Origin` that was actually received, and a missing one
78
+ * is rejected outright. It is also intermittent — it depends on whether the jar
79
+ * happens to hold the session cookie that launch — which is what let it hide.
80
+ *
81
+ * `credentials: "omit"` is the fix, and it costs nothing on native: the session
82
+ * is a bearer there (`setAuthToken` / `setWsToken`), so the cookie was never the
83
+ * credential — only an accident of transport riding along. Wrapped INNERMOST by
84
+ * {@link createLunoraClient}, so it runs after every other layer and also
85
+ * overrides the `credentials: "include"` `@lunora/client` sends on its
86
+ * `get-session` probe.
87
+ *
88
+ * **Scope.** This covers `fetch`, which is the whole HTTP RPC/REST/storage
89
+ * surface. It does NOT cover the WebSocket upgrade — React Native attaches the
90
+ * same jar's cookie to the handshake and exposes no per-socket opt-out — nor
91
+ * `httpStream` imported standalone from `@lunora/client`, which resolves
92
+ * `globalThis.fetch` itself. Neither is a live 403 today: React Native sends an
93
+ * `Origin` equal to the server's on a WS handshake, and better-auth's `bearer`
94
+ * plugin overwrites the session cookie with the bearer rather than deferring to
95
+ * it. Both are noted so the guarantee is not read wider than it is.
96
+ *
97
+ * {@link createLunoraClient} applies it only on native — see
98
+ * {@link isNativeRuntime} — and only to the global `fetch`. Under
99
+ * `react-native-web` the jar is the browser's, `Origin` IS sent, the CSRF guard
100
+ * never fires, and a cookie session is a legitimate setup that this would
101
+ * silently sign out (`getCurrentUser` deliberately sends `credentials:
102
+ * "include"`). A caller-supplied `fetch` is never wrapped either, so a
103
+ * cookie-forwarding SSR transport keeps its credentials; apply this yourself if
104
+ * you want it.
105
+ */
106
+ declare const withoutAmbientCookies: (fetchImpl: typeof fetch) => typeof fetch;
107
+ /**
108
+ * Construct a `LunoraClient` tuned for React Native / Expo — a thin wrapper over
109
+ * `new LunoraClient(options)` that fills in the three things a browser gets for
110
+ * free but React Native does not.
111
+ *
112
+ * First, a durable offline queue: pass `storage` (React Native `AsyncStorage`,
113
+ * or any async key/value store) and the offline mutation queue is persisted
114
+ * through `createAsyncStoragePersistence` — the browser default auto-probes
115
+ * IndexedDB, which React Native lacks, so without this the queue lives only in
116
+ * memory and is lost on reload.
117
+ *
118
+ * Second, a durable read cache: the same `storage` also backs the query cache
119
+ * through `createAsyncStorageQueryCache`, so cached reads render immediately
120
+ * after a restart while the socket reconnects — mirroring the browser's
121
+ * IndexedDB-backed default.
122
+ *
123
+ * Third, credentialed requests: the session is a bearer on this platform, so the
124
+ * returned `fetch` is wrapped in {@link withoutAmbientCookies} (React Native's
125
+ * cookie jar is real, and a stray session cookie 403s every state-changing RPC —
126
+ * see there), and passing `getAuthHeaders` additionally rides its headers on both
127
+ * the HTTP RPC path and the WebSocket upgrade.
128
+ *
129
+ * Everything on `LunoraClientOptions` is still accepted and passed through; an
130
+ * explicit `persistence`, `queryCache`, `fetch`, or `WebSocket` replaces the
131
+ * transport derived from `storage`. A `getAuthHeaders` factory is layered over a
132
+ * caller-supplied `fetch` rather than ignored — the previous behaviour dropped
133
+ * the credential on HTTP while still injecting it on the WS upgrade. `persistence`
134
+ * and `queryCache` override independently — `storage` backs both, so opting out
135
+ * of one leaves the other wired. See the package README
136
+ * for a full setup example.
137
+ * @experimental
138
+ */
74
139
  declare const createLunoraClient: (options: CreateLunoraClientOptions) => LunoraClient;
75
- export { type AuthHeadersFactory, type CreateLunoraClientOptions, createLunoraClient };
140
+ export { type AuthHeadersFactory, type CreateLunoraClientOptions, createLunoraClient, withoutAmbientCookies };
package/dist/index.d.ts CHANGED
@@ -1,75 +1,140 @@
1
1
  import { AsyncStorageLike, LunoraClientOptions, LunoraClient } from '@lunora/client';
2
2
  export * from '@lunora/react';
3
3
  /**
4
- * A `() => headers` factory the React Native client threads onto every HTTP RPC
5
- * request *and* the WebSocket upgrade — a generic escape hatch for attaching a
6
- * **custom** credential header (an API-gateway key, a proxy token, …) that
7
- * React Native's missing cookie jar can't carry implicitly. Return `undefined`
8
- * (or an empty object) when there's nothing to attach.
9
- *
10
- * For better-auth Expo sessions, prefer a **bearer** token instead: read it with
11
- * `@lunora/react-native/auth`'s `expoBearerToken` and feed it to
12
- * `client.setAuthToken` / `setWsToken` (see the package README). A bearer avoids
13
- * the `Cookie` header the runtime's CSRF guard rejects on `Origin`-less native
14
- * requests.
15
- */
4
+ * A `() => headers` factory the React Native client threads onto every HTTP RPC
5
+ * request *and* the WebSocket upgrade — a generic escape hatch for attaching a
6
+ * **custom** credential header (an API-gateway key, a proxy token, …). Return
7
+ * `undefined` (or an empty object) when there's nothing to attach.
8
+ *
9
+ * For better-auth Expo sessions, prefer a **bearer** token instead: read it with
10
+ * `@lunora/react-native/auth`'s `expoBearerToken` and feed it to
11
+ * `client.setAuthToken` / `setWsToken` (see the package README). Either way the
12
+ * client sends `credentials: "omit"`, so the platform cookie jar never attaches a
13
+ * session cookie the runtime's CSRF guard would reject on an `Origin`-less native
14
+ * request.
15
+ * @experimental
16
+ */
16
17
  type AuthHeadersFactory = () => Record<string, string> | undefined;
17
18
  /**
18
- * Options for `createLunoraClient` — the React Native / Expo counterpart to
19
- * constructing a `LunoraClient` directly. It is `LunoraClientOptions` with two
20
- * React-Native-shaped conveniences layered on. `storage` wires the
21
- * AsyncStorage-backed offline-queue persistence for you (the browser default
22
- * auto-probes IndexedDB, which React Native lacks, so without this the queue
23
- * stays in memory and is lost on reload). `getAuthHeaders` is a generic escape
24
- * hatch for a custom credential header on both the HTTP RPC path and the
25
- * WebSocket upgrade; for better-auth sessions prefer a bearer token
26
- * (`expoBearerToken` + the client's `setAuthToken` / `setWsToken`).
27
- *
28
- * The `persistence`, `fetch`, and `WebSocket` fields of `LunoraClientOptions`
29
- * are still accepted and take precedence when you need full control; the two
30
- * conveniences are sugar over exactly those seams.
31
- */
19
+ * Options for `createLunoraClient` — the React Native / Expo counterpart to
20
+ * constructing a `LunoraClient` directly. It is `LunoraClientOptions` with two
21
+ * React-Native-shaped conveniences layered on. `storage` wires the
22
+ * AsyncStorage-backed offline-queue persistence for you (the browser default
23
+ * auto-probes IndexedDB, which React Native lacks, so without this the queue
24
+ * stays in memory and is lost on reload). `getAuthHeaders` is a generic escape
25
+ * hatch for a custom credential header on both the HTTP RPC path and the
26
+ * WebSocket upgrade; for better-auth sessions prefer a bearer token
27
+ * (`expoBearerToken` + the client's `setAuthToken` / `setWsToken`).
28
+ *
29
+ * The `persistence`, `fetch`, and `WebSocket` fields of `LunoraClientOptions`
30
+ * are still accepted and take precedence when you need full control; the two
31
+ * conveniences are sugar over exactly those seams.
32
+ * @experimental
33
+ */
32
34
  interface CreateLunoraClientOptions extends LunoraClientOptions {
33
35
  /**
34
- * Attaches a credential to every HTTP RPC request and to the WebSocket
35
- * upgrade request. See `AuthHeadersFactory`. When omitted, requests go out
36
- * with only whatever `LunoraClient` already sets (its bearer token from
37
- * `setAuthToken`, if any).
38
- *
39
- * Merged UNDER the client's own headers on the HTTP path (an explicit
40
- * `setAuthToken` bearer wins over a same-named header here); applied to the
41
- * WebSocket upgrade via a wrapping `WebSocket`, since the socket has no other
42
- * way to carry credentials in React Native.
43
- */
36
+ * Attaches a credential to every HTTP RPC request and to the WebSocket
37
+ * upgrade request. See `AuthHeadersFactory`. When omitted, requests go out
38
+ * with only whatever `LunoraClient` already sets (its bearer token from
39
+ * `setAuthToken`, if any).
40
+ *
41
+ * Merged UNDER the client's own headers on the HTTP path (an explicit
42
+ * `setAuthToken` bearer wins over a same-named header here); applied to the
43
+ * WebSocket upgrade via a wrapping `WebSocket`, since the socket has no other
44
+ * way to carry credentials in React Native.
45
+ */
44
46
  getAuthHeaders?: AuthHeadersFactory;
45
47
  /**
46
- * React Native `AsyncStorage` (or any async key/value store with the same
47
- * `getItem`/`setItem`/`removeItem` surface — Expo `SecureStore`, an in-memory
48
- * map in tests). When supplied, the offline mutation queue is persisted here
49
- * via `createAsyncStoragePersistence`, so writes made offline survive an app
50
- * restart. Ignored when an explicit `persistence` is passed.
51
- */
48
+ * React Native `AsyncStorage` (or any async key/value store with the same
49
+ * `getItem`/`setItem`/`removeItem` surface — Expo `SecureStore`, an in-memory
50
+ * map in tests). When supplied it backs two independent caches: the offline
51
+ * mutation queue (via `createAsyncStoragePersistence`, so writes made offline
52
+ * survive an app restart) and the durable query cache (via
53
+ * `createAsyncStorageQueryCache`, so reads repaint before the socket
54
+ * reconnects).
55
+ *
56
+ * Each is opted out of by its own option, not by the other: `persistence`
57
+ * overrides the queue and `queryCache` overrides the read cache, so
58
+ * `{ persistence: false, storage }` still writes query results to storage.
59
+ */
52
60
  storage?: AsyncStorageLike;
53
61
  }
54
62
  /**
55
- * Construct a `LunoraClient` tuned for React Native / Expo — a thin wrapper over
56
- * `new LunoraClient(options)` that fills in the two things a browser gets for
57
- * free but React Native does not.
58
- *
59
- * First, a durable offline queue: pass `storage` (React Native `AsyncStorage`,
60
- * or any async key/value store) and the offline mutation queue is persisted
61
- * through `createAsyncStoragePersistence` — the browser default auto-probes
62
- * IndexedDB, which React Native lacks, so without this the queue lives only in
63
- * memory and is lost on reload.
64
- *
65
- * Second, credentialed requests: pass `getAuthHeaders` and the returned headers
66
- * ride both the HTTP RPC path and the WebSocket upgrade, since React Native has
67
- * no cookie jar to attach a session implicitly.
68
- *
69
- * Everything on `LunoraClientOptions` is still accepted and passed through; an
70
- * explicit `persistence`, `fetch`, or `WebSocket` takes precedence over the
71
- * convenience derived from `storage` / `getAuthHeaders`. See the package README
72
- * for a full setup example.
73
- */
63
+ * Wrap a `fetch` so no request carries the platform's ambient cookie credential.
64
+ *
65
+ * React Native **does** have a cookie jar — its `fetch` is backed by the
66
+ * platform HTTP stack (`NSURLSession` / `OkHttp`), which owns a shared,
67
+ * persistent cookie store — so a `Set-Cookie` from a better-auth sign-in is kept
68
+ * and re-attached to later requests automatically. The package's own docs used
69
+ * to assert the opposite, and the bearer design leaned on it: "there is no jar,
70
+ * therefore no `Cookie` header, therefore the runtime's CSRF guard never sees
71
+ * one".
72
+ *
73
+ * It does see one. The guard rejects an unsafe, cookie-bearing request whose
74
+ * `Origin` is missing or untrusted, and a native request sends no `Origin` — so
75
+ * every state-changing RPC 403s with `FORBIDDEN_ORIGIN` for as long as the jar
76
+ * holds that cookie. `security.csrf.trustedOrigins` cannot fix it: the trust list
77
+ * is only consulted for an `Origin` that was actually received, and a missing one
78
+ * is rejected outright. It is also intermittent — it depends on whether the jar
79
+ * happens to hold the session cookie that launch — which is what let it hide.
80
+ *
81
+ * `credentials: "omit"` is the fix, and it costs nothing on native: the session
82
+ * is a bearer there (`setAuthToken` / `setWsToken`), so the cookie was never the
83
+ * credential — only an accident of transport riding along. Wrapped INNERMOST by
84
+ * {@link createLunoraClient}, so it runs after every other layer and also
85
+ * overrides the `credentials: "include"` `@lunora/client` sends on its
86
+ * `get-session` probe.
87
+ *
88
+ * **Scope.** This covers `fetch`, which is the whole HTTP RPC/REST/storage
89
+ * surface. It does NOT cover the WebSocket upgrade — React Native attaches the
90
+ * same jar's cookie to the handshake and exposes no per-socket opt-out — nor
91
+ * `httpStream` imported standalone from `@lunora/client`, which resolves
92
+ * `globalThis.fetch` itself. Neither is a live 403 today: React Native sends an
93
+ * `Origin` equal to the server's on a WS handshake, and better-auth's `bearer`
94
+ * plugin overwrites the session cookie with the bearer rather than deferring to
95
+ * it. Both are noted so the guarantee is not read wider than it is.
96
+ *
97
+ * {@link createLunoraClient} applies it only on native — see
98
+ * {@link isNativeRuntime} — and only to the global `fetch`. Under
99
+ * `react-native-web` the jar is the browser's, `Origin` IS sent, the CSRF guard
100
+ * never fires, and a cookie session is a legitimate setup that this would
101
+ * silently sign out (`getCurrentUser` deliberately sends `credentials:
102
+ * "include"`). A caller-supplied `fetch` is never wrapped either, so a
103
+ * cookie-forwarding SSR transport keeps its credentials; apply this yourself if
104
+ * you want it.
105
+ */
106
+ declare const withoutAmbientCookies: (fetchImpl: typeof fetch) => typeof fetch;
107
+ /**
108
+ * Construct a `LunoraClient` tuned for React Native / Expo — a thin wrapper over
109
+ * `new LunoraClient(options)` that fills in the three things a browser gets for
110
+ * free but React Native does not.
111
+ *
112
+ * First, a durable offline queue: pass `storage` (React Native `AsyncStorage`,
113
+ * or any async key/value store) and the offline mutation queue is persisted
114
+ * through `createAsyncStoragePersistence` — the browser default auto-probes
115
+ * IndexedDB, which React Native lacks, so without this the queue lives only in
116
+ * memory and is lost on reload.
117
+ *
118
+ * Second, a durable read cache: the same `storage` also backs the query cache
119
+ * through `createAsyncStorageQueryCache`, so cached reads render immediately
120
+ * after a restart while the socket reconnects — mirroring the browser's
121
+ * IndexedDB-backed default.
122
+ *
123
+ * Third, credentialed requests: the session is a bearer on this platform, so the
124
+ * returned `fetch` is wrapped in {@link withoutAmbientCookies} (React Native's
125
+ * cookie jar is real, and a stray session cookie 403s every state-changing RPC —
126
+ * see there), and passing `getAuthHeaders` additionally rides its headers on both
127
+ * the HTTP RPC path and the WebSocket upgrade.
128
+ *
129
+ * Everything on `LunoraClientOptions` is still accepted and passed through; an
130
+ * explicit `persistence`, `queryCache`, `fetch`, or `WebSocket` replaces the
131
+ * transport derived from `storage`. A `getAuthHeaders` factory is layered over a
132
+ * caller-supplied `fetch` rather than ignored — the previous behaviour dropped
133
+ * the credential on HTTP while still injecting it on the WS upgrade. `persistence`
134
+ * and `queryCache` override independently — `storage` backs both, so opting out
135
+ * of one leaves the other wired. See the package README
136
+ * for a full setup example.
137
+ * @experimental
138
+ */
74
139
  declare const createLunoraClient: (options: CreateLunoraClientOptions) => LunoraClient;
75
- export { type AuthHeadersFactory, type CreateLunoraClientOptions, createLunoraClient };
140
+ export { type AuthHeadersFactory, type CreateLunoraClientOptions, createLunoraClient, withoutAmbientCookies };
package/dist/index.mjs CHANGED
@@ -1,2 +1 @@
1
- export { createLunoraClient } from './packem_shared/createLunoraClient-D9i-IKbc.mjs';
2
- export * from '@lunora/react';
1
+ import{createLunoraClient as t,withoutAmbientCookies as r}from"./packem_shared/createLunoraClient-WOgHmG35.mjs";export*from"@lunora/react";export{t as createLunoraClient,r as withoutAmbientCookies};
@@ -0,0 +1 @@
1
+ import{LunoraClient as d,createAsyncStorageQueryCache as h,createAsyncStoragePersistence as i}from"@lunora/client";const u=()=>typeof document>"u",f=s=>(t,o)=>s(t,{...o,credentials:"omit"}),b=(s,t)=>(o,e)=>{const r=t();if(!r)return s(o,e);const c=new Headers(r);return e?.headers&&new Headers(e.headers).forEach((n,a)=>{c.set(a,n)}),s(o,{...e,headers:c})},S=(s,t)=>class extends s{constructor(e,r){const c=t();super(e,r,c?{headers:c}:void 0)}},y=s=>{const{getAuthHeaders:t,storage:o,...e}=s,r=e.fetch===void 0&&typeof fetch=="function"?fetch.bind(globalThis):void 0,c=e.fetch??(r&&u()?f(r):r),n=t&&c?b(c,t):c,a=t&&e.WebSocket===void 0&&typeof WebSocket=="function"?S(WebSocket,t):e.WebSocket;return new d({...e,persistence:e.persistence??(o?i({storage:o}):void 0),queryCache:e.queryCache??(o?h({storage:o}):void 0),fetch:n,WebSocket:a})};export{y as createLunoraClient,b as withAuthHeaders,S as withAuthWebSocket,f as withoutAmbientCookies};
@@ -0,0 +1 @@
1
+ const t=/(?:^|;)[^;=]*session_token=([^;]+)/,n=async e=>t.exec(await e.getCookie())?.[1]??null;export{n as default};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/react-native",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.100",
4
4
  "description": "React Native / Expo integration for Lunora: an AsyncStorage-backed client factory, the useQuery/useMutation/useSubscription hooks, and a one-call better-auth Expo client",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -50,13 +50,13 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@lunora/client": "1.0.0-alpha.21",
54
- "@lunora/react": "1.0.0-alpha.24"
53
+ "@lunora/client": "1.0.0-alpha.123",
54
+ "@lunora/react": "1.0.0-alpha.129"
55
55
  },
56
56
  "peerDependencies": {
57
- "@better-auth/expo": "^1.6.23",
57
+ "@better-auth/expo": "^1.7.1",
58
58
  "@tanstack/react-query": "^5.101.0",
59
- "better-auth": "^1.6.23",
59
+ "better-auth": "^1.7.1",
60
60
  "react": "^19.2.7"
61
61
  },
62
62
  "peerDependenciesMeta": {
@@ -1,36 +0,0 @@
1
- import { LunoraClient, createAsyncStoragePersistence } from '@lunora/client';
2
-
3
- const withAuthHeaders = (fetchImpl, getAuthHeaders) => (input, init) => {
4
- const extra = getAuthHeaders();
5
- if (!extra) {
6
- return fetchImpl(input, init);
7
- }
8
- const merged = new Headers(extra);
9
- if (init?.headers) {
10
- new Headers(init.headers).forEach((value, key) => {
11
- merged.set(key, value);
12
- });
13
- }
14
- return fetchImpl(input, { ...init, headers: merged });
15
- };
16
- const withAuthWebSocket = (WebSocketImpl, getAuthHeaders) => class AuthWebSocket extends WebSocketImpl {
17
- constructor(url, protocols) {
18
- const headers = getAuthHeaders();
19
- super(url, protocols, headers ? { headers } : void 0);
20
- }
21
- };
22
- const createLunoraClient = (options) => {
23
- const { getAuthHeaders, storage, ...rest } = options;
24
- const authedFetch = getAuthHeaders && rest.fetch === void 0 && typeof fetch === "function" ? withAuthHeaders(fetch.bind(globalThis), getAuthHeaders) : rest.fetch;
25
- const authedWebSocket = getAuthHeaders && rest.WebSocket === void 0 && typeof WebSocket === "function" ? withAuthWebSocket(WebSocket, getAuthHeaders) : rest.WebSocket;
26
- return new LunoraClient({
27
- ...rest,
28
- // Auto-wire AsyncStorage persistence unless the caller passed an explicit
29
- // `persistence` (including `false` to opt out).
30
- persistence: rest.persistence ?? (storage ? createAsyncStoragePersistence({ storage }) : void 0),
31
- fetch: authedFetch,
32
- WebSocket: authedWebSocket
33
- });
34
- };
35
-
36
- export { createLunoraClient, withAuthHeaders, withAuthWebSocket };
@@ -1,7 +0,0 @@
1
- const SESSION_TOKEN_COOKIE = /(?:^|;)[^;=]*session_token=([^;]+)/;
2
- const expoBearerToken = (authClient) => {
3
- const match = SESSION_TOKEN_COOKIE.exec(authClient.getCookie());
4
- return match?.[1] ?? null;
5
- };
6
-
7
- export { expoBearerToken as default };