@molecule/app-auth 1.8.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-10-23T18:55:10.568Z
6
+ Generated: 2026-10-25T20:03:32.020Z
7
7
  -->
8
8
 
9
9
  # @molecule/app-auth
@@ -44,8 +44,8 @@ The first read of the client (`getState()`, `subscribe()`/`onAuthChange()`, a
44
44
  and an explicit `initialize()` returns the same promise, so a second call asks the
45
45
  server nothing. Keep the template's explicit call (it makes the first render
46
46
  deterministic); an entry file that drops it still restores instead of reporting
47
- `'loading'` forever (xr-molecule-flash-m192: a `main.tsx` rewrite dropped the line
48
- and 18 tests timed out on pages that never left their loading branch).
47
+ `'loading'` forever (a `main.tsx` rewrite can drop the line, and every page
48
+ then stays on its loading branch).
49
49
 
50
50
  **Never fetch viewer-dependent data before the restore settles.** A
51
51
  component effect that runs on mount fires while `initialize()` is still
@@ -71,12 +71,12 @@ mirrors the token into the bonded client.
71
71
  `initialize()` sets that state synchronously, before any request, and every
72
72
  verification settles behind that render. Never hold the first paint on
73
73
  `validateSession()` or a `/me` request of your own — a protected page then pays
74
- an API round trip on every navigation (xr-molecule-flash-m177: ~30 full page
75
- loads in one pagination spec; its four tests sat at 15.0–15.9 s of a 15 s
76
- budget and the grader timed two out). Route guards read this same `status`:
77
- wait only while it is `'loading'` — that status means "no decision yet", so an
78
- auth request in flight (`state.loading`) keeps the decided status and does not
79
- unmount a Login/Register page mid-request (xr-molecule-flash-m380).
74
+ an API round trip on every navigation (~30 full page loads in one pagination
75
+ spec put four tests at 15.0–15.9 s of a 15 s budget, and two timed out). Route
76
+ guards read this same `status`: wait only while it is `'loading'` — that
77
+ status means "no decision yet", so an auth request in flight
78
+ (`state.loading`) keeps the decided status and does not unmount a
79
+ Login/Register page mid-request.
80
80
  `validateSession()` is the background re-check that produces the last two: call
81
81
  it after the first render — a 401 or 403 clears the token (`'unauthenticated'`),
82
82
  while a network failure, a 5xx, or a 404 from a wrong `currentUserEndpoint`
@@ -121,18 +121,18 @@ redirect or the app's own sign-in endpoint has already issued the token and retu
121
121
  user, `setSession({ accessToken, user })` stores both and flips the client to
122
122
  `authenticated` in one update, so every hook reading the shared client is signed in at once
123
123
  (a form's own sign-in code wrote the token and the user with `setAccessToken`/`setUser`,
124
- which change their field only — the UI stayed signed out and nine browser assertions timed
125
- out, xr-molecule-flash-m178). `setAccessToken()` and `setUser()` are field setters, not a
124
+ which change their field only — the UI stays signed out and the browser assertions that
125
+ expect it time out). `setAccessToken()` and `setUser()` are field setters, not a
126
126
  sign-in: neither sets `authenticated`.
127
127
 
128
128
  **A custom user profile is typed once and used everywhere: `AuthClientConfig<MyUser>`,
129
129
  `createJWTAuthClient<MyUser>` and `useAuth<MyUser>()`.** The config's generic makes
130
130
  `mapAuthResponse` return `AuthResult<MyUser>`, so a mapper that omits a required custom
131
131
  field fails at the factory call instead of shipping a signed-in page whose navbar reads
132
- `undefined` (xr-molecule-flash-m255: the mapper kept `id`/`name`/`avatar` but dropped
133
- `username`, and the signed-in controls never rendered although login succeeded). Keep the
134
- wire user's fields and ADD the base identity fields; do not rebuild the profile field by
135
- field, and never hide a mismatch with `as unknown as MyUser`:
132
+ `undefined` — a mapper that keeps `id`/`name`/`avatar` but drops `username` leaves the
133
+ signed-in controls unrendered although login succeeded. The
134
+ wire user carries the base fields too, `id` included — spread it, do not rebuild it
135
+ field by field, and never hide a mismatch with `as unknown as MyUser`:
136
136
 
137
137
  ```typescript
138
138
  import type { AuthClientConfig, UserProfile } from '@molecule/app-auth'
@@ -145,18 +145,12 @@ interface MyUser extends UserProfile {
145
145
 
146
146
  const config: AuthClientConfig<MyUser> = {
147
147
  mapAuthResponse: (data) => {
148
- const { user: wire } = data as { user: { token: string } & Omit<MyUser, 'id'> }
149
- return {
150
- accessToken: wire.token,
151
- user: {
152
- // Every wire field the pages read (`username`, `image`, …) survives…
153
- ...wire,
154
- // …plus the base fields the client derives its identity from.
155
- id: wire.username,
156
- name: wire.name ?? wire.username,
157
- avatar: wire.image,
158
- },
159
- }
148
+ // The wire is the app's profile plus the bearer; `id` is the server's.
149
+ const { user: wire } = data as { user: { token: string } & MyUser }
150
+ // The bearer stays OUT of the profile: the client caches `user` in Web
151
+ // Storage, where a token copy is readable by any script on the origin.
152
+ const { token, ...profile } = wire
153
+ return { accessToken: token, user: profile }
160
154
  },
161
155
  }
162
156
 
@@ -164,11 +158,55 @@ const client = createJWTAuthClient<MyUser>(config)
164
158
  // React: `useAuth<MyUser>()` reads the same profile.
165
159
  ```
166
160
 
167
- A username-derived `id` can change during a profile save. It is not an immutable
168
- session identifier: `getSessionRevision()` tracks session ownership independently
169
- of profile fields, remains unchanged for profile updates and same-session
170
- refreshes, and changes when a session is replaced or ended. Compare revisions
171
- only within one client. React's `useProfileForm` uses this capability by default.
161
+ That `id` is the API's account identifier. Do not derive one from a display field
162
+ (`id: wire.username`) to borrow `UserProfile` for an API that has no id: the field
163
+ changes, the fabricated id changes with it, and the value then reaches storage,
164
+ `getUser()` and every page as if the server had said it.
165
+
166
+ A profile does not have to extend `UserProfile` and does not need an `id`: the client
167
+ stores and patches the object it is given and reads no field of it. Name the API's own
168
+ shape, pass it to the factory and the config, and have the mapper return it complete —
169
+ every configuration adapter is typed by the same `T` (`mapAuthResponse`, the
170
+ `profileEndpoint`/`profileUpdateMethod`/`profileRequestBody` write transport,
171
+ `shouldRestoreUser`, and `setSession`):
172
+
173
+ ```typescript
174
+ import type { AuthClientConfig } from '@molecule/app-auth'
175
+ import { createJWTAuthClient } from '@molecule/app-auth'
176
+
177
+ interface SessionUser {
178
+ username: string
179
+ email: string
180
+ bio: string
181
+ image: string
182
+ }
183
+
184
+ const config: AuthClientConfig<SessionUser> = {
185
+ mapAuthResponse: (data) => {
186
+ const { user: wire } = data as { user: { token: string } & SessionUser }
187
+ // The bearer stays OUT of the profile: the client caches `user` in Web
188
+ // Storage, where a token copy is readable by any script on the origin.
189
+ const { token, ...profile } = wire
190
+ return { accessToken: token, user: profile }
191
+ },
192
+ profileEndpoint: (user) => '/people/' + encodeURIComponent(user.username),
193
+ }
194
+
195
+ const client = createJWTAuthClient<SessionUser>(config)
196
+ // `client.getUser()` has `bio`/`image`; there is no `id` to fabricate.
197
+ ```
198
+
199
+ The profile is named at the factory, never inferred from the mapper: an untyped
200
+ `createJWTAuthClient({ mapAuthResponse })` stays `UserProfile`, whose required
201
+ `id: string` is the default configuration's contract. An array, a function or a
202
+ `Date` is not a profile the client can persist — see `AuthProfileShape`.
203
+
204
+ A profile field can change during a profile save — a handle, or a username an app
205
+ once derived an `id` from. It is not an immutable session identifier:
206
+ `getSessionRevision()` tracks session ownership independently of profile fields,
207
+ remains unchanged for profile updates and same-session refreshes, and changes when
208
+ a session is replaced or ended. Compare revisions only within one client. React's
209
+ `useProfileForm` uses this capability by default.
172
210
 
173
211
  **A profile-update transport that is not `PATCH` with bare JSON is a CONFIG field, not a
174
212
  hand-written client.** `updateProfile()` sends to `profileEndpoint` with `PATCH` and the
@@ -203,7 +241,11 @@ the client to change a method or an envelope also replaces the lifecycle that ke
203
241
  coherent, and hand-written adapters have had to repair those callbacks repeatedly.
204
242
  A pending profile response is published only to the session that sent it:
205
243
  signing out or explicitly adopting/signing in another session prevents the old
206
- response from replacing the active user's stored profile.
244
+ response from changing the active user's stored profile. The answer is applied onto the
245
+ stored profile field by field: a field it carries replaces the stored value (`null` clears
246
+ it) and one it leaves out — or that `mapAuthResponse` leaves `undefined` — is kept, so a
247
+ field the API clears by leaving it out must reach the client as `null`, answered by the API
248
+ or mapped by `mapAuthResponse`.
207
249
 
208
250
  **A background verification that returns an unchanged user changes nothing.** When
209
251
  `validateSession()` (or a reload's restore) answers with the same profile data the client
@@ -257,16 +299,26 @@ fields with the factory's second generic, and `requestBody` receives exactly wha
257
299
  the form passed — `{ user: { username, email, password } }` goes out as-is.
258
300
 
259
301
  ```typescript
260
- import type { UserProfile } from '@molecule/app-auth'
261
302
  import { createJWTAuthClient } from '@molecule/app-auth'
262
303
 
263
- const realWorldAuth = createJWTAuthClient<UserProfile, { username: string }>({
304
+ // RealWorld's user has no `id`. Name the shape the wire actually sends; the
305
+ // client reads no field of it, so nothing has to be invented for a base profile.
306
+ interface RealWorldUser {
307
+ username: string
308
+ email: string
309
+ bio: string
310
+ image: string
311
+ }
312
+
313
+ const realWorldAuth = createJWTAuthClient<RealWorldUser, { username: string }>({
264
314
  baseURL: '/api',
265
315
  authScheme: 'Token',
266
316
  requestBody: (_kind, payload) => ({ user: payload }),
267
317
  mapAuthResponse: (data) => {
268
- const { user } = data as { user: { token: string; username: string; email: string } }
269
- return { accessToken: user.token, user: { id: user.username, email: user.email } }
318
+ const { user: wire } = data as { user: { token: string } & RealWorldUser }
319
+ // Keep every wire field; the bearer stays out of the cached profile.
320
+ const { token, ...profile } = wire
321
+ return { accessToken: token, user: profile }
270
322
  },
271
323
  })
272
324
 
@@ -676,9 +728,12 @@ interface AuthClient<
676
728
 
677
729
  /**
678
730
  * Updates the current user's profile.
679
- * Returns the saved response. If the session is explicitly replaced or ended
680
- * while the request is pending, that response does not replace the current
681
- * session's user or persisted cache.
731
+ * Returns the saved profile: the answer applied onto the signed-in user field
732
+ * by field, with or without an `id` — a field it carries replaces the value
733
+ * (`null` clears it), one it omits or leaves `undefined` is kept, so a field
734
+ * cleared by omission must arrive as `null`. A non-object answer is returned
735
+ * as it came. An answer for a replaced or ended session leaves the current one
736
+ * untouched.
682
737
  */
683
738
  updateProfile(data: Partial<T>): Promise<T>
684
739
 
@@ -742,9 +797,6 @@ interface AuthClient<
742
797
  * called, and after it has finished. A restore that failed resolves too (it settled the
743
798
  * question), so an HTTP client can await this before every request without risking one
744
799
  * held forever. That is what makes the fetch-before-restore race impossible: a page that
745
- * fetches viewer-dependent data on mount waits here instead of going out anonymously
746
- * (see `setAuthGate` in `@molecule/app-http`).
747
- *
748
800
  // … truncated — the full declaration is in node_modules/@molecule/app-auth/dist/types.d.ts
749
801
  ```
750
802
 
@@ -761,23 +813,15 @@ interface StorageAdapter {
761
813
  }
762
814
  ```
763
815
 
764
- #### `AuthClientConfig`
816
+ #### `AuthClientConfigOptions`
765
817
 
766
- Configuration options for creating an auth client, including API
767
- endpoints, storage strategy, token refresh, and OAuth providers.
818
+ Every option {@link AuthClientConfig} accepts, typed by the profile.
768
819
 
769
- Type it with the app's user — `AuthClientConfig<MyUser>` — the same type
770
- `createJWTAuthClient<MyUser>` returns and `useAuth<MyUser>()` reads. That
771
- makes {@link mapAuthResponse} return `AuthResult<MyUser>`, so a mapper that
772
- omits a required custom field is a type error instead of a signed-in page
773
- reading `undefined` (xr-molecule-flash-m255). Keep every wire field the pages
774
- read (spread the wire user and add `id`/`name`/`avatar`); mapping a field to a
775
- different name does not preserve the original, and a `as unknown as MyUser`
776
- assertion hides the loss. See the module docs for the wire-preserving
777
- adapter.
820
+ Use `AuthClientConfig<T>` rather than this type: it adds the profile-shape
821
+ check. This interface exists so each member's documentation is addressable.
778
822
 
779
823
  ````typescript
780
- interface AuthClientConfig<T extends UserProfile = UserProfile> {
824
+ interface AuthClientConfigOptions<T extends object = UserProfile> {
781
825
  /**
782
826
  * API base URL.
783
827
  */
@@ -807,6 +851,8 @@ interface AuthClientConfig<T extends UserProfile = UserProfile> {
807
851
  * The write route used by `updateProfile`, independently of `currentUserEndpoint`.
808
852
  * Defaults to `/auth/profile`. A resource route with an account id can resolve
809
853
  * from the signed-in user, e.g. `(user) => '/accounts/' + encodeURIComponent(user.id)`.
854
+ * A profile with no `id` resolves from a field it does have, e.g.
855
+ * `(user) => '/people/' + encodeURIComponent(user.username)`.
810
856
  * The resolver sees the current user before the patch, never caller-submitted identity.
811
857
  */
812
858
  profileEndpoint?: string | ((user: Readonly<T>) => string)
@@ -893,7 +939,16 @@ interface AuthClientConfig<T extends UserProfile = UserProfile> {
893
939
  /**
894
940
  * Turn an answer that carries a user into the client's
895
941
  * `{ accessToken, refreshToken?, user }` (default: the answer as given).
896
- * RealWorld, for example: `(data) => ({ accessToken: data.user.token, user: data.user })`.
942
+ * RealWorld, for example — keep the wire bearer OUT of the profile, because
943
+ * the client caches `user` in Web Storage where a token copy is readable by
944
+ * any script on the origin:
945
+ *
946
+ * ```typescript
947
+ * (data) => {
948
+ * const { token, ...user } = data.user
949
+ * return { accessToken: token, user }
950
+ * }
951
+ * ```
897
952
  *
898
953
  * It is the ONE user-shape adapter, and the client applies it on EVERY path
899
954
  * that adopts a user from an API answer — `login()`, `register()`,
@@ -901,17 +956,26 @@ interface AuthClientConfig<T extends UserProfile = UserProfile> {
901
956
  * token-refresh answer, and `updateProfile()`. Those answers carry the raw wire
902
957
  * user (a current-user route typically answers a bare `{ user }`), so applying
903
958
  * the same mapping is what keeps the adapted profile after a reload instead of
904
- * replacing it with the unadapted one (xr-molecule-flash-m184). A mapper that
959
+ * replacing it with the unadapted one. A mapper that
905
960
  * cannot read a given answer (it throws, or returns no `user`) leaves that
906
961
  * answer's user as it came.
907
962
  *
908
963
  * It is NOT applied to `setSession()` or `setUser()`: those receive a profile
909
964
  * the caller already built in this client's shape, not a wire answer.
910
965
  *
966
+ * The return type does NOT infer the profile: an untyped
967
+ * `createJWTAuthClient({ mapAuthResponse })` stays `UserProfile`, so a mapper
968
+ * that returns a no-id profile fails to compile there instead of silently
969
+ * replacing the default with whatever the mapper happened to return. The same
970
+ * rule holds for every config member below (`profileEndpoint`,
971
+ * `profileRequestBody`, `shouldRestoreUser`): none of them supplies the
972
+ * profile. Name the profile at the call
973
+ * (`createJWTAuthClient<MyUser>({ mapAuthResponse })`) to use a custom one.
974
+ *
911
975
  * @param data - The parsed response body.
912
976
  * @returns The auth result.
913
977
  */
914
- mapAuthResponse?: (data: unknown) => AuthResult<T>
978
+ mapAuthResponse?: (data: unknown) => AuthResult<NoInfer<T>>
915
979
 
916
980
  /**
917
981
  * Where the access/refresh token and the cached user are kept — the field that
@@ -936,25 +1000,6 @@ interface AuthClientConfig<T extends UserProfile = UserProfile> {
936
1000
  * A bearer token in Web Storage is readable by any script on the origin, so an
937
1001
  * XSS can steal it; see {@link createBrowserStorageAdapter}'s `@remarks` for the
938
1002
  * trade and for the cookie alternative. Never answer a suite's storage key with a
939
- * second store or a restore script beside this client — that races the client's
940
- * own restore, and either side's 401 signs the user out for good.
941
- *
942
- * Any adapter with `getItem`/`setItem`/`removeItem` works — the methods are
943
- * SYNCHRONOUS, so the async `@molecule/app-storage` provider
944
- * (`getProvider()`, whose `get()` returns a Promise) is NOT one: wrap the
945
- * `Storage` object it was built on (`createBrowserStorageAdapter({ storage })`),
946
- * or store a different way.
947
- *
948
- * @example
949
- * ```typescript
950
- * import { createBrowserStorageAdapter } from '@molecule/app-auth'
951
- *
952
- * // A suite/host page reads the token at its own key (the browser default
953
- * // already stores it; `storage` is shown for an explicit choice):
954
- * storage: createBrowserStorageAdapter()
955
- * tokenKey: 'jwtToken'
956
- * // Only this tab's lifetime, at a narrower exposure than localStorage:
957
- * storage: createBrowserStorageAdapter({ kind: 'session' })
958
1003
  // … truncated — the full declaration is in node_modules/@molecule/app-auth/dist/types.d.ts
959
1004
  ````
960
1005
 
@@ -987,6 +1032,86 @@ Which Web Storage area {@link createBrowserStorageAdapter} persists to:
987
1032
  type BrowserStorageKind = 'local' | 'session'
988
1033
  ```
989
1034
 
1035
+ #### `AuthProfileShape`
1036
+
1037
+ The profile shapes the auth client accepts: a JSON object type of named
1038
+ fields, and nothing else.
1039
+
1040
+ The client does not read a field of the profile, but it does persist it:
1041
+ `tokenStorage.setUser` writes it with `JSON.stringify` and `getUser` restores
1042
+ it with `JSON.parse`, and `updateProfile(data: Partial<T>)` patches it
1043
+ field-wise. An array has no named fields to read or patch, `JSON.stringify`
1044
+ drops a function entirely, and a `Date` comes back from `JSON.parse` as a
1045
+ string while still typed as a `Date` — so those three types are rejected on
1046
+ every entrypoint that takes a profile type (`createJWTAuthClient`,
1047
+ the default-auth factories, `AuthClientConfig`).
1048
+
1049
+ The check is on the TOP-LEVEL shape only: a class instance, or a `Date` in a
1050
+ nested field, is the app's own persistence contract, not this validator's.
1051
+ A named `interface`, a type literal, an intersection and the empty object
1052
+ type all satisfy it — `{}` compiles because it is a JSON object that
1053
+ round-trips unchanged, not an invalid form.
1054
+
1055
+ A union is checked member by member: `ExistingUser | ExistingUser[]` contains
1056
+ an array, so it is not an object-only profile either, and the shared
1057
+ rejection test (`AuthProfileIsRejected`, used by
1058
+ {@link AuthProfileConstraint} and {@link AuthProfileGuard}) rejects it as a
1059
+ whole.
1060
+
1061
+ ```typescript
1062
+ type AuthProfileShape<T> = T extends readonly unknown[] | ((...args: never[]) => unknown) | Date
1063
+ ? AuthProfileRejection
1064
+ : object
1065
+ ```
1066
+
1067
+ #### `AuthProfileGuard`
1068
+
1069
+ Requires a profile type to satisfy {@link AuthProfileShape}: an invalid
1070
+ profile makes {@link AuthClientConfig} impossible to populate, so the
1071
+ annotation itself is the error. It shares the entrypoints' rejection test,
1072
+ so a union with an invalid member is rejected here too.
1073
+
1074
+ ```typescript
1075
+ type AuthProfileGuard<T> =
1076
+ AuthProfileIsRejected<T> extends true
1077
+ ? { readonly [K in AuthProfileRejection]: never }
1078
+ : Record<never, never>
1079
+ ```
1080
+
1081
+ #### `AuthProfileConstraint`
1082
+
1083
+ The constraint every entrypoint puts on a profile type: a JSON object type of
1084
+ named fields ({@link AuthProfileShape}), and `object` for `any`.
1085
+
1086
+ `any` is the caller's explicit opt-out of profile typing, not an invalid JSON
1087
+ shape: `createJWTAuthClient<any>()`, `getClient<any>()` and
1088
+ `AuthClientConfig<any>` name it deliberately. It is detected explicitly
1089
+ (`0 extends 1 & T`) and accepted. Every other type is measured against
1090
+ {@link AuthProfileShape}, member by member when it is a union: a union that
1091
+ holds an array, a function or a `Date` is rejected as a whole, so
1092
+ `createJWTAuthClient<ExistingUser | ExistingUser[]>()` is the same type error
1093
+ as `createJWTAuthClient<ExistingUser[]>()`.
1094
+
1095
+ Forwarding a profile through your own generic copies this constraint, so the
1096
+ profile keeps being checked:
1097
+
1098
+ ```typescript
1099
+ import type { AuthClientConfig, AuthProfileConstraint } from '@molecule/app-auth'
1100
+ import { createJWTAuthClient } from '@molecule/app-auth'
1101
+
1102
+ export function makeAuth<T extends AuthProfileConstraint<T>>(config: AuthClientConfig<T>) {
1103
+ return createJWTAuthClient<T>(config)
1104
+ }
1105
+ ```
1106
+
1107
+ `T extends object` is not enough: the factory rejects arrays, functions and
1108
+ `Date` at its own constraint, so a forwarder written that way does not
1109
+ compile. Copy this type instead.
1110
+
1111
+ ```typescript
1112
+ type AuthProfileConstraint<T> = AuthProfileIsRejected<T> extends true ? never : object
1113
+ ```
1114
+
990
1115
  #### `RegisterExtraShape`
991
1116
 
992
1117
  The shape an app's own registration fields (`TRegister`) must satisfy.
@@ -1091,11 +1216,15 @@ type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' | 'unavailable
1091
1216
 
1092
1217
  Auth event types.
1093
1218
 
1219
+ A sign-in event carries the client's own profile type, so a listener on a
1220
+ client typed with the app's profile reads that profile — including one that
1221
+ does not extend {@link UserProfile}. The default is {@link UserProfile}.
1222
+
1094
1223
  ```typescript
1095
- type AuthEvent =
1096
- | { type: 'login'; user: UserProfile }
1224
+ type AuthEvent<T = UserProfile> =
1225
+ | { type: 'login'; user: T }
1097
1226
  | { type: 'logout' }
1098
- | { type: 'register'; user: UserProfile }
1227
+ | { type: 'register'; user: T }
1099
1228
  | { type: 'refresh' }
1100
1229
  | { type: 'error'; error: string }
1101
1230
  ```
@@ -1104,8 +1233,63 @@ type AuthEvent =
1104
1233
 
1105
1234
  Callback invoked when an authentication event occurs (login, logout, etc.).
1106
1235
 
1236
+ A listener is typed by the client's profile: `AuthEventListener<MyUser>`
1237
+ receives `AuthEvent<MyUser>`, so a sign-in event's `user` is the app's own
1238
+ shape — including a profile that does not extend {@link UserProfile}. The
1239
+ default is {@link UserProfile}.
1240
+
1241
+ An inline arrow is contextually typed, so its `event` parameter needs no
1242
+ annotation. Because listener parameters are contravariant, a client whose
1243
+ profile extends {@link UserProfile} also accepts a listener written against
1244
+ the default `AuthEventListener` (those events carry every `UserProfile`
1245
+ field). A client whose profile does NOT extend `UserProfile` — an API user
1246
+ with no `id` — accepts only `AuthEventListener<T>`: a default listener would
1247
+ read an `id` the runtime user does not have.
1248
+
1249
+ **Migration to 2.0.** External providers that implement {@link AuthClient}
1250
+ declare their listener parameter as `AuthEventListener<T>`
1251
+ (`addEventListener(listener: AuthEventListener<T>): () => void`). The
1252
+ previous `addEventListener(listener: AuthEventListener)` declaration, which
1253
+ claimed every client's events carry a `UserProfile`, is no longer assignable
1254
+ once the client's profile is a custom one.
1255
+
1256
+ ```typescript
1257
+ type AuthEventListener<T = UserProfile> = (event: AuthEvent<T>) => void
1258
+ ```
1259
+
1260
+ #### `AuthClientConfig`
1261
+
1262
+ Configuration options for creating an auth client, including API
1263
+ endpoints, storage strategy, token refresh, and OAuth providers.
1264
+
1265
+ Type it with the app's user — `AuthClientConfig<MyUser>` — the same type
1266
+ `createJWTAuthClient<MyUser>` returns and `useAuth<MyUser>()` reads. That
1267
+ makes {@link AuthClientConfigOptions.mapAuthResponse} return
1268
+ `AuthResult<MyUser>`, so a mapper that omits a required custom field is a type
1269
+ error instead of a signed-in page reading `undefined`. Keep every wire field the pages
1270
+ read by spreading the
1271
+ wire user — the base fields (`id`, `name`, `avatar`) come from the server like
1272
+ any other, never re-derived from a mutable display field such as a username;
1273
+ mapping a field to a different name does not preserve the original, and an
1274
+ `as unknown as MyUser` assertion hides the loss. See the module docs for the
1275
+ wire-preserving adapter.
1276
+
1277
+ `T` is the app's OWN profile shape and need not extend {@link UserProfile}:
1278
+ the client stores and patches the object it is given and reads no field of
1279
+ it, so an API whose user has no `id` — `{ username, email, bio, image }` —
1280
+ is typed by its own `interface` directly, with no fabricated `id`. The
1281
+ default, {@link UserProfile}, still requires `id: string`. A primitive,
1282
+ `null`, an array, a function or a `Date` — or a union containing one — is
1283
+ rejected; see {@link AuthProfileShape} for the persistence contract behind
1284
+ that list.
1285
+
1286
+ The type argument is NEVER inferred from the config: not from
1287
+
1288
+ _(description truncated — read the full text in `node_modules/@molecule/app-auth/dist/types.d.ts`.)_
1289
+
1107
1290
  ```typescript
1108
- type AuthEventListener = (event: AuthEvent) => void
1291
+ type AuthClientConfig<T extends object = UserProfile> = AuthClientConfigOptions<T> &
1292
+ AuthProfileGuard<T>
1109
1293
  ```
1110
1294
 
1111
1295
  ### Functions
@@ -1118,8 +1302,8 @@ The auth client's `StorageAdapter` over the browser's Web Storage.
1118
1302
  token AND the user in the store, `initialize()` restores the authenticated state synchronously
1119
1303
  (`whenRestored()` is already resolved on the first render); render from it, and validate the
1120
1304
  token as a background request — never in front of the first render. A page that waits on its
1121
- own `/me` call pays a round trip on EVERY navigation (xr-molecule-flash-m122: a 1–5 s restore
1122
- window, eight `waitForURL` timeouts).
1305
+ own `/me` call pays a round trip on EVERY navigation: a 1–5 s restore window is enough to
1306
+ time out a navigation that expects the signed-in UI.
1123
1307
 
1124
1308
  In a browser this IS the auth client's default `AuthClientConfig.storage`, so a scaffolded
1125
1309
  app keeps its session across a page reload with no extra wiring. Outside a browser (SSR, a Node
@@ -1160,8 +1344,25 @@ Creates a simple JWT-based auth client.
1160
1344
  This is a basic implementation that can be extended or replaced
1161
1345
  with more sophisticated auth providers.
1162
1346
 
1347
+ `T` is the profile the API returns. The default, `UserProfile`, requires
1348
+ `id: string`, and an untyped call — `createJWTAuthClient({ mapAuthResponse })`
1349
+ — KEEPS that default: the profile is never inferred from the config (no
1350
+ member of it, and not from an annotated config variable), so a mapper that
1351
+ returns a user without `id` fails to compile instead of quietly becoming the
1352
+ client's profile. To use the app's own profile, name it here,
1353
+ `createJWTAuthClient<SessionUser>(config)`; a profile with no `id`
1354
+ (`{ username, email, bio, image }`) is then used as-is. Pass the same type to
1355
+ the config (`AuthClientConfig<SessionUser>`), so `mapAuthResponse` and the
1356
+ write transport are checked against the profile the client and the React
1357
+ hooks hand to your pages.
1358
+
1359
+ `any` is accepted (`createJWTAuthClient<any>()`) as the caller's explicit
1360
+ opt-out; array, function and `Date` profiles are rejected. A wrapper that
1361
+ forwards a profile type copies `AuthProfileConstraint` (see
1362
+ {@link AuthClientConfig}'s migration note).
1363
+
1163
1364
  ```typescript
1164
- function createJWTAuthClient(config?: AuthClientConfig<T>): AuthClient<T, TRegister>
1365
+ function createJWTAuthClient(config?: NoInfer<AuthClientConfigOptions<T>>): AuthClient<T, TRegister>
1165
1366
  ```
1166
1367
 
1167
1368
  - `config` — Auth client configuration including endpoints, storage, and refresh settings.
@@ -1281,7 +1482,7 @@ function getUser(): T | null
1281
1482
  Logs in with the given credentials (email/username + password).
1282
1483
 
1283
1484
  ```typescript
1284
- function login(credentials: LoginCredentials): Promise<AuthResult<UserProfile>>
1485
+ function login(credentials: LoginCredentials): Promise<AuthResult<T>>
1285
1486
  ```
1286
1487
 
1287
1488
  - `credentials` — Email/username and password.
@@ -1302,8 +1503,12 @@ function logout(): Promise<void>
1302
1503
 
1303
1504
  Registers a new user account.
1304
1505
 
1506
+ The registration extra fields stay the FIRST type argument, exactly as before
1507
+ (`register<{ username: string }>(data)` keeps compiling); the profile is the
1508
+ second one (`register<{ username: string }, SessionUser>(data)`).
1509
+
1305
1510
  ```typescript
1306
- function register(data: RegisterData<TExtra>): Promise<AuthResult<UserProfile>>
1511
+ function register(data: RegisterData<TExtra>): Promise<AuthResult<T>>
1307
1512
  ```
1308
1513
 
1309
1514
  - `data` — Registration data (email, password, and any `TExtra` fields).
@@ -1328,7 +1533,8 @@ Peer dependencies:
1328
1533
 
1329
1534
  - **An API with its own auth envelope keeps this client.** `requestBody` wraps the
1330
1535
  login/register payload (`(kind, p) => ({ user: p })`), `mapAuthResponse` reads the
1331
- token and user out of the answer (`(d) => ({ accessToken: d.user.token, user: d.user })`),
1536
+ token and user out of the answer — keeping the wire bearer OUT of the profile,
1537
+ because the client caches `user` in Web Storage (see the mapper example below) —
1332
1538
  and `authScheme: 'Token'` sends `Authorization: Token <jwt>`. `mapAuthResponse` is the
1333
1539
  ONE user-shape adapter: the client applies it to every answer that carries a user (login,
1334
1540
  register, `validateSession()`, the cookie restore, a token refresh, `updateProfile()`), so
@@ -1349,6 +1555,16 @@ Peer dependencies:
1349
1555
  - Client-side auth state is UX only — the server enforces authorization on
1350
1556
  every request; hiding a screen is not protection.
1351
1557
 
1558
+ The mapper for such an envelope, with the wire token kept out of the cached
1559
+ profile (`d` is the parsed answer):
1560
+
1561
+ ```typescript
1562
+ ;(d) => {
1563
+ const { token, ...user } = d.user
1564
+ return { accessToken: token, user }
1565
+ }
1566
+ ```
1567
+
1352
1568
  ## E2E Tests
1353
1569
 
1354
1570
  Integration checklist — drive the real UI (live preview, no mocks), adapt
@@ -35,8 +35,8 @@ export interface BrowserStorageAdapterOptions {
35
35
  * token AND the user in the store, `initialize()` restores the authenticated state synchronously
36
36
  * (`whenRestored()` is already resolved on the first render); render from it, and validate the
37
37
  * token as a background request — never in front of the first render. A page that waits on its
38
- * own `/me` call pays a round trip on EVERY navigation (xr-molecule-flash-m122: a 1–5 s restore
39
- * window, eight `waitForURL` timeouts).
38
+ * own `/me` call pays a round trip on EVERY navigation: a 1–5 s restore window is enough to
39
+ * time out a navigation that expects the signed-in UI.
40
40
  *
41
41
  * In a browser this IS the auth client's default `AuthClientConfig.storage`, so a scaffolded
42
42
  * app keeps its session across a page reload with no extra wiring. Outside a browser (SSR, a Node
@@ -26,8 +26,8 @@ const warn = (message, error) => {
26
26
  * token AND the user in the store, `initialize()` restores the authenticated state synchronously
27
27
  * (`whenRestored()` is already resolved on the first render); render from it, and validate the
28
28
  * token as a background request — never in front of the first render. A page that waits on its
29
- * own `/me` call pays a round trip on EVERY navigation (xr-molecule-flash-m122: a 1–5 s restore
30
- * window, eight `waitForURL` timeouts).
29
+ * own `/me` call pays a round trip on EVERY navigation: a 1–5 s restore window is enough to
30
+ * time out a navigation that expects the signed-in UI.
31
31
  *
32
32
  * In a browser this IS the auth client's default `AuthClientConfig.storage`, so a scaffolded
33
33
  * app keeps its session across a page reload with no extra wiring. Outside a browser (SSR, a Node
package/dist/client.d.ts CHANGED
@@ -5,21 +5,38 @@
5
5
  *
6
6
  * @module
7
7
  */
8
- import type { AuthClient, AuthClientConfig, RegisterExtraShape, UserProfile } from './types.js';
8
+ import type { AuthClient, AuthClientConfigOptions, AuthProfileConstraint, RegisterExtraShape, UserProfile } from './types.js';
9
9
  /**
10
10
  * Creates a simple JWT-based auth client.
11
11
  *
12
12
  * This is a basic implementation that can be extended or replaced
13
13
  * with more sophisticated auth providers.
14
14
  *
15
- * @template T - The user profile the API returns. Pass it to the config too
16
- * (`AuthClientConfig<T>`), so `mapAuthResponse` is checked against the same
17
- * profile the client and the React hook hand to your pages.
15
+ * `T` is the profile the API returns. The default, `UserProfile`, requires
16
+ * `id: string`, and an untyped call — `createJWTAuthClient({ mapAuthResponse })`
17
+ * — KEEPS that default: the profile is never inferred from the config (no
18
+ * member of it, and not from an annotated config variable), so a mapper that
19
+ * returns a user without `id` fails to compile instead of quietly becoming the
20
+ * client's profile. To use the app's own profile, name it here,
21
+ * `createJWTAuthClient<SessionUser>(config)`; a profile with no `id`
22
+ * (`{ username, email, bio, image }`) is then used as-is. Pass the same type to
23
+ * the config (`AuthClientConfig<SessionUser>`), so `mapAuthResponse` and the
24
+ * write transport are checked against the profile the client and the React
25
+ * hooks hand to your pages.
26
+ *
27
+ * `any` is accepted (`createJWTAuthClient<any>()`) as the caller's explicit
28
+ * opt-out; array, function and `Date` profiles are rejected. A wrapper that
29
+ * forwards a profile type copies `AuthProfileConstraint` (see
30
+ * {@link AuthClientConfig}'s migration note).
31
+ *
32
+ * @template T - The user profile the API returns: a JSON object type of named
33
+ * fields (see {@link AuthProfileConstraint}), including a profile with no `id`.
34
+ * The default, `UserProfile`, requires `id: string`.
18
35
  * @template TRegister - The app's own registration fields, accepted at the top
19
36
  * level of `register()` (e.g. `{ username: string }` for a RealWorld-shaped API).
20
37
  * An interface and a type alias are equally valid.
21
38
  * @param config - Auth client configuration including endpoints, storage, and refresh settings.
22
39
  * @returns A fully configured `AuthClient` instance.
23
40
  */
24
- export declare const createJWTAuthClient: <T extends UserProfile = UserProfile, TRegister extends RegisterExtraShape<TRegister> = Record<never, never>>(config?: AuthClientConfig<T>) => AuthClient<T, TRegister>;
41
+ export declare const createJWTAuthClient: <T extends AuthProfileConstraint<T> = UserProfile, TRegister extends RegisterExtraShape<TRegister> = Record<never, never>>(config?: NoInfer<AuthClientConfigOptions<T>>) => AuthClient<T, TRegister>;
25
42
  //# sourceMappingURL=client.d.ts.map