@lenne.tech/nuxt-extensions 1.8.3 → 1.9.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/CLAUDE.md CHANGED
@@ -13,7 +13,7 @@ src/
13
13
  │ ├── lib/ # Library exports (auth-client, auth-state, ai)
14
14
  │ ├── middleware/ # Route middleware (system setup)
15
15
  │ ├── plugins/ # Nuxt plugins (auth interceptor, error translation, config check)
16
- │ ├── server/ # Nitro server routes (auth proxy)
16
+ │ ├── server/ # server-side tsconfig scope (no runtime routes; auth talks to the backend directly)
17
17
  │ ├── testing/ # Playwright test helpers
18
18
  │ ├── types/ # TypeScript type definitions
19
19
  │ └── utils/ # Auto-imported utility functions
@@ -85,17 +85,17 @@ The API base URL is resolved at runtime from `NUXT_PUBLIC_API_URL` (client + SSR
85
85
  ## Authentication (Better Auth)
86
86
 
87
87
  - SSR-safe session management via `useLtAuth()`
88
- - Auth proxy server route at `/api/auth/**` (proxies to backend)
89
- - Middleware `auth` for protected routes
88
+ - Auth requests go **directly** to the backend Better-Auth endpoints (`basePath: '/iam'`) via `ltAuthFetch` (Cookie/JWT dual-mode) — the module ships **no** local Nitro `/api/auth/**` proxy
89
+ - Protected-route gating is the consumer's own middleware (see "Custom Auth Middleware Pattern" below); the module only registers the optional `lt-system-setup` middleware
90
90
  - Passkey (WebAuthn) support via `@better-auth/passkey` peer dependency
91
91
  - 2FA (TOTP) support built-in
92
92
 
93
93
  ### Auth Flow
94
94
 
95
95
  1. Frontend calls `useLtAuth().signIn()` / `.signUp()`
96
- 2. Request goes through Nitro proxy at `/api/auth/**`
97
- 3. Proxy forwards to backend Better Auth endpoints
98
- 4. Session cookie set via `httpOnly` cookie (SSR-safe)
96
+ 2. The Better-Auth client sends the request straight to the backend Better-Auth endpoints (`basePath: '/iam'`), with the base URL resolved by `buildLtApiUrl` (`NUXT_PUBLIC_API_URL` / SSR `NUXT_API_URL`)
97
+ 3. The backend sets the session as an `httpOnly` cookie (`iam.session_token`); `lt-auth-state` is the non-httpOnly client-side cache used by middleware
98
+ 4. Subsequent authenticated requests use `ltAuthFetch` (Cookie mode by default, JWT fallback)
99
99
 
100
100
  ## AI Assistant Flow
101
101
 
@@ -137,9 +137,21 @@ The `useLtAuth()` composable manages the `lt-auth-state` cookie for authenticati
137
137
  On session re-validation (`validateSession()` at app init / hard reload, and the passkey get-session fallback), `useLtAuth()` **merges** the Better-Auth session user onto the cached user of the SAME identity via the private `mergeSessionUser()`, instead of overwriting it. Consequences you must know:
138
138
 
139
139
  - **nest-server-only fields persist across reloads.** Better-Auth's get-session returns only Better-Auth-owned fields (id/email/name + registered additionalFields). Fields it does not own (e.g. custom preferences like `leadTableColumns`) previously vanished on every reload; they now survive. If you built a workaround that re-fetched such fields after each reload, you can retire it.
140
- - **Authorization fields are fail-closed.** `role`, `banned`, `banExpires`, `banReason`, `emailVerified`, `twoFactorEnabled` always reflect the session — any of these the session omits is dropped from the merge (never kept stale), so a backend downgrade is reflected client-side. If your project adds its OWN authorization-relevant user field, either register it as a Better-Auth additionalField (so get-session returns it) or add it to the `AUTHZ_KEYS` list in `use-lt-auth.ts` — otherwise it could persist stale in the client cache.
140
+ - **Authorization fields are fail-closed.** `role`, `roles`, `banned`, `banExpires`, `banReason`, `emailVerified`, `twoFactorEnabled` always reflect the session — any of these the session omits is dropped from the merge (never kept stale), so a backend downgrade is reflected client-side. If your project adds its OWN authorization-relevant user field, either register it as a Better-Auth additionalField (so get-session returns it) or add it to the `AUTHZ_KEYS` list in `use-lt-auth.ts` — otherwise it could persist stale in the client cache.
141
141
  - **Keep the cached user lean.** `lt-auth-state` is a non-httpOnly cookie sent on every same-site request and capped at ~4 KB by browsers. Because the merge keeps preference fields across reloads, large blobs on the user object can push the cookie over the limit — the browser then silently rejects the write and the next reload reads as a logout. `setUser` emits a dev-mode warning above ~3.5 KB; move large preference data off the user object and fetch it from an authenticated API.
142
142
 
143
+ ### Admin Detection (`role` vs `roles`):
144
+ `useLtAuth().isAdmin` accepts BOTH user shapes, so one frontend works against either backend:
145
+
146
+ - **`@lenne.tech/nest-server`** registers `roles` as a *core* Better-Auth additionalField (`type: 'string[]'`, `defaultValue: []`). Its users carry `roles: ['admin']` and no singular `role`.
147
+ - **Better-Auth's admin plugin** uses the singular `role: 'admin'`.
148
+
149
+ `isAdmin` is `true` when `role === 'admin'` **or** `roles` contains `'admin'` (a union — either shape granting admin is sufficient). A malformed non-array `roles` (the `lt-auth-state` cookie is client-writable) degrades to `false` — it never throws and never fail-opens via `String.prototype.includes` substring matching. Matching is case-sensitive (`'admin'`, not `'Admin'`), identical to nest-server's own `hasRole(RoleEnum.ADMIN)`.
150
+
151
+ - `roles` is in `AUTHZ_KEYS`, so a backend-side admin revocation closes the admin UI on the next session re-validation instead of being masked by a stale cached array.
152
+ - The `auth.enableAdmin` module option toggles the Better-Auth **admin client plugin** (user-management calls like `admin.listUsers()`). It does NOT control `isAdmin` — role detection works regardless of that flag.
153
+ - **`isAdmin` is a UX gate, not an authorization boundary.** It reads the non-httpOnly `lt-auth-state` cache, which a user can edit. Render admin UI behind it; enforce admin rights server-side (`@Restricted(RoleEnum.ADMIN)`).
154
+
143
155
  ### Custom Auth Middleware Pattern:
144
156
  ```typescript
145
157
  // CORRECT — read-only check, no cookie mutation
package/README.md CHANGED
@@ -166,6 +166,7 @@ export default defineNuxtConfig({
166
166
  const {
167
167
  user,
168
168
  isAuthenticated,
169
+ isAdmin,
169
170
  isLoading,
170
171
  signIn,
171
172
  signOut,
@@ -199,6 +200,42 @@ async function handlePasskeyLogin() {
199
200
  </template>
200
201
  ```
201
202
 
203
+ #### Admin detection (`role` vs `roles`)
204
+
205
+ `isAdmin` accepts **both** user shapes, so the same frontend works against either backend:
206
+
207
+ | Backend | User shape | `isAdmin` is `true` when |
208
+ |---------|------------|-------------------------|
209
+ | `@lenne.tech/nest-server` | `roles: ['admin']` — core Better-Auth additionalField (`type: 'string[]'`), no singular `role` | `roles` contains `'admin'` |
210
+ | Better-Auth admin plugin | `role: 'admin'` — single role | `role === 'admin'` |
211
+
212
+ ```vue
213
+ <script setup>
214
+ const { isAdmin } = useLtAuth();
215
+ </script>
216
+
217
+ <template>
218
+ <NuxtLink v-if="isAdmin" to="/admin">Admin</NuxtLink>
219
+ </template>
220
+ ```
221
+
222
+ > **`isAdmin` is a UX gate, not an authorization boundary.** It reads the client-side `lt-auth-state` cookie cache, which is not httpOnly and therefore user-writable. Use it to decide what to *render*; always enforce admin rights server-side (`@Restricted(RoleEnum.ADMIN)` in nest-server).
223
+ >
224
+ > The `auth.enableAdmin` option toggles the Better-Auth **admin client plugin** (user-management calls such as `admin.listUsers()`). It does **not** control `isAdmin` — role detection works regardless of that flag.
225
+
226
+ For roles other than admin, use `hasRole(role)` / `hasAnyRole(...roles)`. They apply the exact same both-shapes union and `Array.isArray` guard as `isAdmin` (which is itself just `hasRole('admin')`), so you never need the unguarded `user.value?.roles?.includes(...)`:
227
+
228
+ ```vue
229
+ <script setup>
230
+ const { hasRole, hasAnyRole } = useLtAuth();
231
+ </script>
232
+
233
+ <template>
234
+ <button v-if="hasRole('editor')">Edit</button>
235
+ <NuxtLink v-if="hasAnyRole('admin', 'editor')" to="/manage">Manage</NuxtLink>
236
+ </template>
237
+ ```
238
+
202
239
  ### Custom Better Auth Plugins
203
240
 
204
241
  You can extend the auth client with additional [Better Auth plugins](https://www.better-auth.com/docs/plugins):
@@ -46,7 +46,17 @@ export function useLtAuth() {
46
46
  const isJwtMode = computed(() => authMode.value === "jwt");
47
47
  const user = computed(() => resolvedAuthState.value?.user ?? null);
48
48
  const isAuthenticated = computed(() => !!user.value);
49
- const isAdmin = computed(() => user.value?.role === "admin");
49
+ function hasRole(role) {
50
+ const current = user.value;
51
+ if (!current) {
52
+ return false;
53
+ }
54
+ return current.role === role || Array.isArray(current.roles) && current.roles.includes(role);
55
+ }
56
+ function hasAnyRole(...roles) {
57
+ return roles.some((role) => hasRole(role));
58
+ }
59
+ const isAdmin = computed(() => hasRole("admin"));
50
60
  const is2FAEnabled = computed(() => user.value?.twoFactorEnabled ?? false);
51
61
  const features = useState("lt-auth-features", () => ({}));
52
62
  const featuresFetched = useState("lt-auth-features-fetched", () => false);
@@ -129,7 +139,7 @@ export function useLtAuth() {
129
139
  }
130
140
  return response;
131
141
  }
132
- const AUTHZ_KEYS = ["banExpires", "banReason", "banned", "emailVerified", "role", "twoFactorEnabled"];
142
+ const AUTHZ_KEYS = ["banExpires", "banReason", "banned", "emailVerified", "role", "roles", "twoFactorEnabled"];
133
143
  function mergeSessionUser(sessionUser) {
134
144
  const cached = resolvedAuthState.value?.user;
135
145
  if (!cached?.id || !sessionUser?.id || cached.id !== sessionUser.id) {
@@ -457,6 +467,8 @@ export function useLtAuth() {
457
467
  isLoading: computed(() => isLoading.value),
458
468
  user,
459
469
  // User properties
470
+ hasAnyRole,
471
+ hasRole,
460
472
  is2FAEnabled,
461
473
  isAdmin,
462
474
  // Feature detection
@@ -2,10 +2,17 @@
2
2
  * Auth Interceptor Plugin
3
3
  *
4
4
  * This plugin intercepts all API responses and handles session expiration.
5
- * When a 401 (Unauthorized) response is received, it automatically:
5
+ * When a 401 (Unauthorized) response is received, it verifies against the
6
+ * session endpoint that the session is genuinely dead and then automatically:
6
7
  * 1. Clears the user session state
7
8
  * 2. Redirects to the login page
8
9
  *
10
+ * The verification step exists because a 401 from a domain endpoint is not
11
+ * proof of an expired session: backends may mislabel permission errors
12
+ * (authenticated user, missing rights — semantically 403) as 401. Logging out
13
+ * on those would kick a logged-in user out of the app for a mere missing
14
+ * right. Only a dead session may clear state.
15
+ *
9
16
  * Note: This is a client-only plugin (.client.ts) since auth state
10
17
  * management only makes sense in the browser context.
11
18
  */
@@ -1,4 +1,5 @@
1
1
  import { useLtAuth } from "../composables/auth/use-lt-auth.js";
2
+ import { getLtApiBase } from "../lib/auth-state.js";
2
3
  export default (nuxtApp) => {
3
4
  if (import.meta.server) return;
4
5
  let _authInstance = null;
@@ -42,6 +43,19 @@ export default (nuxtApp) => {
42
43
  ];
43
44
  return authEndpoints.some((endpoint) => url.includes(endpoint));
44
45
  }
46
+ async function isSessionStillAlive() {
47
+ try {
48
+ const { fetchWithAuth } = getAuth();
49
+ const response = await fetchWithAuth(`${getLtApiBase()}/get-session`, { method: "GET" });
50
+ if (!response.ok) {
51
+ return false;
52
+ }
53
+ const data = await response.json().catch(() => null);
54
+ return Boolean(data && (data.user || data.session));
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
45
59
  async function handleUnauthorized(requestUrl) {
46
60
  if (isHandling401) {
47
61
  return;
@@ -56,6 +70,13 @@ export default (nuxtApp) => {
56
70
  try {
57
71
  const { clearUser, isAuthenticated } = getAuth();
58
72
  if (isAuthenticated.value) {
73
+ const sessionAlive = await isSessionStillAlive();
74
+ if (sessionAlive !== false) {
75
+ console.debug(
76
+ sessionAlive ? `[LtAuth Interceptor] 401 from ${requestUrl ?? "unknown URL"} but session is still valid \u2014 treating it as a permission error, not logging out` : "[LtAuth Interceptor] 401 received but session state could not be verified \u2014 not logging out"
77
+ );
78
+ return;
79
+ }
59
80
  console.debug("[LtAuth Interceptor] Session expired, logging out...");
60
81
  clearUser();
61
82
  const router = nuxtApp.$router;
@@ -12,7 +12,22 @@ export interface LtUser {
12
12
  id: string;
13
13
  image?: string;
14
14
  name?: string;
15
+ /** Single-role shape (Better-Auth admin plugin). See also {@link LtUser.roles}. */
15
16
  role?: string;
17
+ /**
18
+ * Multi-role shape, and the canonical home for this fact: `@lenne.tech/nest-server`
19
+ * registers `roles` as a core Better-Auth additionalField (`type: 'string[]'`,
20
+ * `defaultValue: []`), so its users carry `roles: ['admin']` and usually NO
21
+ * singular `role`.
22
+ *
23
+ * NOT a source of authorization truth: this lives in the non-httpOnly
24
+ * `lt-auth-state` cookie and is therefore client-writable. It is kept
25
+ * fail-closed on session merge (see `AUTHZ_KEYS` in `use-lt-auth.ts`) so a
26
+ * backend downgrade is never masked by a stale value — that is a *staleness*
27
+ * guarantee, not a security guarantee. `isAdmin` reads it for UI gating;
28
+ * authorization is enforced server-side (`@Restricted(RoleEnum.ADMIN)`).
29
+ */
30
+ roles?: string[];
16
31
  twoFactorEnabled?: boolean;
17
32
  }
18
33
  /**
@@ -94,7 +109,50 @@ export interface UseLtAuthReturn {
94
109
  isLoading: ComputedRef<boolean>;
95
110
  jwtToken: Ref<string | null>;
96
111
  user: ComputedRef<LtUser | null>;
112
+ /**
113
+ * Returns `true` when the current user has ANY of the given roles (union).
114
+ * Same shape-tolerance and UX-gate-only caveats as {@link UseLtAuthReturn.hasRole}.
115
+ *
116
+ * @example
117
+ * // <NuxtLink v-if="hasAnyRole('admin', 'editor')" to="/manage">Manage</NuxtLink>
118
+ */
119
+ hasAnyRole: (...roles: string[]) => boolean;
120
+ /**
121
+ * Returns `true` when the current user has `role` in EITHER supported shape:
122
+ * `role === role` (Better-Auth admin plugin) or `roles` containing it
123
+ * (`@lenne.tech/nest-server`; see {@link LtUser.roles}). A malformed non-array
124
+ * `roles` degrades to `false` — never throws, never fail-opens via
125
+ * `String.prototype.includes` substring matching. Reads `user` live, so call it
126
+ * from a template or `computed` for reactivity.
127
+ *
128
+ * Prefer this over the raw `user.value?.roles?.includes(x)`, which is unguarded
129
+ * against the string-`roles` substring-confusion this method closes.
130
+ *
131
+ * UX gate only — the `lt-auth-state` cache is client-writable; enforce rights
132
+ * server-side.
133
+ *
134
+ * @example
135
+ * // <button v-if="hasRole('editor')">Edit</button>
136
+ * @see {@link LtUser.role}
137
+ * @see {@link LtUser.roles}
138
+ */
139
+ hasRole: (role: string) => boolean;
97
140
  is2FAEnabled: ComputedRef<boolean>;
141
+ /**
142
+ * `true` when the current user is an admin in EITHER supported shape:
143
+ * `role === 'admin'` (Better-Auth admin plugin) or `roles` containing `'admin'`
144
+ * (`@lenne.tech/nest-server`; see {@link LtUser.roles}). Equivalent to
145
+ * `hasRole('admin')`. A malformed non-array `roles` degrades to `false` (never
146
+ * throws, never fail-opens). Independent of the `enableAdmin` module option,
147
+ * which only toggles the Better-Auth admin *client plugin*.
148
+ *
149
+ * UX gate only: the `lt-auth-state` cache it reads is client-writable, so treat
150
+ * this as "what to render", never as the gate itself. Enforce admin rights
151
+ * server-side.
152
+ *
153
+ * @see {@link UseLtAuthReturn.hasRole}
154
+ * @see {@link LtUser.roles}
155
+ */
98
156
  isAdmin: ComputedRef<boolean>;
99
157
  features: ComputedRef<Record<string, boolean | number | string[]>>;
100
158
  fetchFeatures: () => Promise<Record<string, boolean | number | string[]>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nuxt-extensions",
3
- "version": "1.8.3",
3
+ "version": "1.9.0",
4
4
  "description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "author": "lenne.Tech <info@lenne.tech> (https://lenne.tech)",
14
14
  "license": "MIT",
15
- "packageManager": "pnpm@10.33.0",
15
+ "packageManager": "pnpm@11.13.0",
16
16
  "type": "module",
17
17
  "sideEffects": false,
18
18
  "exports": {
@@ -113,25 +113,6 @@
113
113
  "vitest": "4.1.7",
114
114
  "vue-tsc": "3.3.3"
115
115
  },
116
- "pnpm": {
117
- "onlyBuiltDependencies": [
118
- "@parcel/watcher",
119
- "esbuild"
120
- ],
121
- "overrides": {
122
- "brace-expansion@>=5.0.0 <5.0.6": "5.0.6",
123
- "devalue@>=5.6.3 <=5.8.0": "5.8.1",
124
- "kysely@>=0.26.0 <0.28.17": "0.28.17",
125
- "ws@>=8.0.0 <8.20.1": "8.21.0",
126
- "shell-quote@<1.8.4": "1.8.4",
127
- "nuxt@<4.4.7": "4.4.7",
128
- "vite@>=7.0.0 <7.3.5": "7.3.5",
129
- "tar@<7.5.16": "7.5.16",
130
- "launch-editor@<2.14.1": "2.14.1",
131
- "esbuild@<0.28.1": "0.28.1",
132
- "@babel/core@<7.29.6": "7.29.6"
133
- }
134
- },
135
116
  "keywords": [
136
117
  "nuxt",
137
118
  "nuxt-module",
@@ -144,5 +125,8 @@
144
125
  "tus-upload",
145
126
  "file-upload",
146
127
  "lenne-tech"
147
- ]
128
+ ],
129
+ "engines": {
130
+ "node": ">= 22"
131
+ }
148
132
  }