@lenne.tech/nuxt-extensions 1.8.1 → 1.8.3

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
@@ -12,7 +12,7 @@ src/
12
12
  │ ├── components/ # Auto-imported components
13
13
  │ ├── lib/ # Library exports (auth-client, auth-state, ai)
14
14
  │ ├── middleware/ # Route middleware (system setup)
15
- │ ├── plugins/ # Nuxt plugins (auth interceptor, error translation)
15
+ │ ├── plugins/ # Nuxt plugins (auth interceptor, error translation, config check)
16
16
  │ ├── server/ # Nitro server routes (auth proxy)
17
17
  │ ├── testing/ # Playwright test helpers
18
18
  │ ├── types/ # TypeScript type definitions
@@ -133,6 +133,13 @@ The `useLtAuth()` composable manages the `lt-auth-state` cookie for authenticati
133
133
  3. If `useLtAuth()` can't find a valid user in the cookie on the server, it initializes with `{user: null}` — this is intentional and correct
134
134
  4. The `iam.session_token` httpOnly cookie (set by Better Auth) is the actual session identifier — `lt-auth-state` is a convenience cache for the client-side middleware
135
135
 
136
+ ### Session User Merge (nest-server-only fields):
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
+
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.
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
+
136
143
  ### Custom Auth Middleware Pattern:
137
144
  ```typescript
138
145
  // CORRECT — read-only check, no cookie mutation
package/README.md CHANGED
@@ -55,14 +55,16 @@ npm install tus-js-client
55
55
  All environment variables are resolved **at runtime** (not build time). This means you can build a Docker image once and deploy it to different environments by changing env vars — no rebuild needed.
56
56
 
57
57
  **SSR fallback chain:**
58
- `NUXT_API_URL` → `NUXT_PUBLIC_API_URL` → `auth.baseURL` (from nuxt.config.ts) → `http://localhost:3000`
58
+ `NUXT_API_URL` → `NUXT_PUBLIC_API_URL` → `auth.baseURL` (from nuxt.config.ts) → *(unset)*
59
59
 
60
60
  **Client fallback chain (no proxy):**
61
- `NUXT_PUBLIC_API_URL` → `auth.baseURL` (from nuxt.config.ts) → `http://localhost:3000`
61
+ `NUXT_PUBLIC_API_URL` → `auth.baseURL` (from nuxt.config.ts) → *(unset)*
62
62
 
63
63
  **Client with proxy (`NUXT_PUBLIC_API_PROXY=true`):**
64
64
  All requests go to `/api/{path}` — the Vite dev proxy forwards them to the backend.
65
65
 
66
+ > **No implicit `localhost` default.** If none of the sources above is set, there is **no** built-in fallback URL: API paths stay relative (e.g. `/iam/token`) and resolve against the app origin, so auth and setup calls 404 unless the app is served behind a same-origin reverse proxy. The module logs a one-time warning (`[LtExtensions] No API URL configured…`) at app init and on the first API call. Always set `NUXT_PUBLIC_API_URL` (and optionally `NUXT_API_URL` for SSR).
67
+
66
68
  > **Security:** `NUXT_API_URL` is never exposed to the client bundle. It stays in `runtimeConfig.apiUrl` (server only). This is important when using internal network addresses like `http://api.svc.cluster.local`.
67
69
 
68
70
  ### Deployment Scenarios
package/dist/module.mjs CHANGED
@@ -170,6 +170,7 @@ const module$1 = defineNuxtModule({
170
170
  filePath: resolve(`./runtime/components/transition/${componentName}.vue`)
171
171
  });
172
172
  }
173
+ addPlugin(resolve("./runtime/plugins/lt-config-check"));
173
174
  if (resolvedOptions.auth?.enabled && resolvedOptions.auth?.interceptor?.enabled) {
174
175
  addPlugin(resolve("./runtime/plugins/auth-interceptor.client"));
175
176
  }
@@ -58,7 +58,13 @@ export function useLtAuth() {
58
58
  if (import.meta.client) {
59
59
  const maxAge = 60 * 60 * 24 * 7;
60
60
  const secure = globalThis.location?.protocol === "https:" ? "; secure" : "";
61
- document.cookie = `${stateCookieName}=${encodeURIComponent(JSON.stringify(newState))}; path=/; max-age=${maxAge}; samesite=lax${secure}`;
61
+ const encoded = encodeURIComponent(JSON.stringify(newState));
62
+ if (import.meta.dev && encoded.length > 3500) {
63
+ console.warn(
64
+ `[lt-auth] lt-auth-state cookie is ${encoded.length} bytes (encoded), approaching the ~4KB browser limit \u2014 an oversized cookie is dropped on write and reads as a logout on reload. Keep the cached user lean: move large preference data off the user object.`
65
+ );
66
+ }
67
+ document.cookie = `${stateCookieName}=${encoded}; path=/; max-age=${maxAge}; samesite=lax${secure}`;
62
68
  }
63
69
  }
64
70
  function clearUser() {
@@ -123,6 +129,20 @@ export function useLtAuth() {
123
129
  }
124
130
  return response;
125
131
  }
132
+ const AUTHZ_KEYS = ["banExpires", "banReason", "banned", "emailVerified", "role", "twoFactorEnabled"];
133
+ function mergeSessionUser(sessionUser) {
134
+ const cached = resolvedAuthState.value?.user;
135
+ if (!cached?.id || !sessionUser?.id || cached.id !== sessionUser.id) {
136
+ return sessionUser;
137
+ }
138
+ const merged = { ...cached, ...sessionUser };
139
+ for (const key of AUTHZ_KEYS) {
140
+ if (!(key in sessionUser)) {
141
+ delete merged[key];
142
+ }
143
+ }
144
+ return merged;
145
+ }
126
146
  async function validateSession() {
127
147
  try {
128
148
  const session = authClient.useSession();
@@ -141,7 +161,7 @@ export function useLtAuth() {
141
161
  });
142
162
  }
143
163
  if (session.value.data?.user) {
144
- setUser(session.value.data.user, "cookie");
164
+ setUser(mergeSessionUser(session.value.data.user), "cookie");
145
165
  switchToJwtMode().catch(() => {
146
166
  });
147
167
  return true;
@@ -297,8 +317,9 @@ export function useLtAuth() {
297
317
  if (sessionResponse.ok) {
298
318
  const sessionData = await sessionResponse.json();
299
319
  if (sessionData?.user) {
300
- result.user = sessionData.user;
301
- setUser(sessionData.user, "cookie");
320
+ const mergedUser = mergeSessionUser(sessionData.user);
321
+ result.user = mergedUser;
322
+ setUser(mergedUser, "cookie");
302
323
  switchToJwtMode().catch(() => {
303
324
  });
304
325
  }
@@ -90,6 +90,14 @@ export declare function getLtAuthCookieNames(): {
90
90
  * unchanged by the duplicate-tolerant read; it only decides which twin wins.
91
91
  */
92
92
  export declare function resolveLtAuthState(cookieString: string, stateCookieName?: string): LtAuthState | null;
93
+ /**
94
+ * Clear the one-shot warning state.
95
+ *
96
+ * INTERNAL — test-only. Module-scope state survives every test in a file that
97
+ * does not call `vi.resetModules()`, which would make assertions on these
98
+ * warnings depend on test order. Production code must never call this.
99
+ */
100
+ export declare function resetLtWarnOnceState(): void;
93
101
  /**
94
102
  * Determines if HTTP requests should use the Nuxt Vite dev proxy.
95
103
  *
@@ -124,6 +132,46 @@ export declare function resolveLtAuthState(cookieString: string, stateCookieName
124
132
  * @see https://github.com/lenneTech/nuxt-base-starter — Vite proxy configuration
125
133
  */
126
134
  export declare function isLocalDevApiProxy(): boolean;
135
+ /**
136
+ * Outcome of resolving the API base URL for the current render scope.
137
+ *
138
+ * INTERNAL — plumbing for {@link buildLtApiUrl} and the `lt-config-check` plugin.
139
+ * Not part of the public API (absent from the package barrels and auto-imports);
140
+ * reachable only via a deep path import. Shape may change without a major bump.
141
+ */
142
+ export interface LtApiUrlResolution {
143
+ /** Base URL to prefix onto API paths. `'/api'` in proxy mode, `''` when unresolved. */
144
+ baseUrl: string;
145
+ /** `true` when no API URL is configured for {@link scope}. */
146
+ missing: boolean;
147
+ /** `true` when the Vite dev proxy prefix is in use (see {@link isLocalDevApiProxy}). */
148
+ proxy: boolean;
149
+ /** The render scope the resolution was performed for. */
150
+ scope: 'client' | 'server';
151
+ }
152
+ /**
153
+ * Resolve the API base URL for the current render scope.
154
+ *
155
+ * INTERNAL — plumbing shared by {@link buildLtApiUrl} and the `lt-config-check`
156
+ * plugin; not part of the public API (absent from the barrels and auto-imports).
157
+ *
158
+ * Pure: it reads `runtimeConfig` and reports what it found, but never logs and
159
+ * never mutates. The `missing` flag lets callers decide how to surface the
160
+ * misconfiguration — {@link buildLtApiUrl} warns lazily, the `lt-config-check`
161
+ * plugin warns eagerly at app init. Both funnel through the same one-shot key,
162
+ * so the user sees exactly one warning either way.
163
+ *
164
+ * Throws when called outside a Nuxt runtime-config context; callers catch.
165
+ */
166
+ export declare function resolveLtApiBaseUrl(): LtApiUrlResolution;
167
+ /**
168
+ * Warn — at most once per module instance — that no API URL is configured.
169
+ *
170
+ * INTERNAL — shared by {@link buildLtApiUrl} and the `lt-config-check` plugin so
171
+ * an eager boot-time check and a lazy first-URL check never double-report. Not
172
+ * part of the public API (absent from the barrels and auto-imports).
173
+ */
174
+ export declare function warnMissingLtApiUrl(scope: 'client' | 'server'): void;
127
175
  /**
128
176
  * Build a full API URL for a given path, handling SSR, proxy, and direct modes.
129
177
  *
@@ -157,6 +205,9 @@ export declare function isLocalDevApiProxy(): boolean;
157
205
  *
158
206
  * Trailing slashes on the base URL are automatically stripped.
159
207
  *
208
+ * A missing API URL is reported at most once per module instance — the condition
209
+ * is constant for the process, so repeating it per call only drowns other output.
210
+ *
160
211
  * @param path - The API path (e.g., `/system-setup/status`, `/i18n/errors/de`)
161
212
  */
162
213
  export declare function buildLtApiUrl(path: string): string;
@@ -42,7 +42,17 @@ export function resolveLtAuthState(cookieString, stateCookieName) {
42
42
  }
43
43
  return fallback;
44
44
  }
45
- let _proxyFallbackWarned = false;
45
+ const warnedKeys = /* @__PURE__ */ new Set();
46
+ function warnOnce(key, message) {
47
+ if (warnedKeys.has(key)) {
48
+ return;
49
+ }
50
+ warnedKeys.add(key);
51
+ console.warn(message);
52
+ }
53
+ export function resetLtWarnOnceState() {
54
+ warnedKeys.clear();
55
+ }
46
56
  export function isLocalDevApiProxy() {
47
57
  if (import.meta.server) {
48
58
  return false;
@@ -55,12 +65,10 @@ export function isLocalDevApiProxy() {
55
65
  }
56
66
  const buildId = runtimeConfig?.app && runtimeConfig.app?.buildId;
57
67
  if (buildId === "dev") {
58
- if (!_proxyFallbackWarned) {
59
- _proxyFallbackWarned = true;
60
- console.warn(
61
- "\n\u26A0\uFE0F [LtExtensions] API proxy activated implicitly because nuxt dev was detected.\n To make this explicit, add NUXT_PUBLIC_API_PROXY=true to your .env file.\n If you do NOT want the proxy, set NUXT_PUBLIC_API_PROXY=false.\n"
62
- );
63
- }
68
+ warnOnce(
69
+ "api-proxy-fallback",
70
+ "\n\u26A0\uFE0F [LtExtensions] API proxy activated implicitly because nuxt dev was detected.\n To make this explicit, add NUXT_PUBLIC_API_PROXY=true to your .env file.\n If you do NOT want the proxy, set NUXT_PUBLIC_API_PROXY=false.\n"
71
+ );
64
72
  return true;
65
73
  }
66
74
  return false;
@@ -68,26 +76,37 @@ export function isLocalDevApiProxy() {
68
76
  return false;
69
77
  }
70
78
  }
79
+ const MISSING_API_URL_WARNING = {
80
+ client: "[LtExtensions] No API URL configured. Set NUXT_PUBLIC_API_URL. API paths stay relative and resolve against the app origin, so auth and setup calls 404 unless the app is served behind a same-origin reverse proxy.",
81
+ server: "[LtExtensions] No API URL configured for SSR. Set NUXT_API_URL or NUXT_PUBLIC_API_URL. API paths stay relative, so server-side requests never reach the backend: $fetch resolves them against Nitro (404) and global fetch rejects them as an invalid URL."
82
+ };
83
+ function stripTrailingSlashes(url) {
84
+ return url.replace(/\/+$/, "");
85
+ }
86
+ export function resolveLtApiBaseUrl() {
87
+ const runtimeConfig = useRuntimeConfig();
88
+ const publicUrl = runtimeConfig.public.apiUrl || "";
89
+ const authBaseURL = runtimeConfig.public?.ltExtensions?.auth?.baseURL || "";
90
+ if (import.meta.server) {
91
+ const apiUrl2 = runtimeConfig.apiUrl || publicUrl || authBaseURL;
92
+ return { baseUrl: stripTrailingSlashes(apiUrl2), missing: !apiUrl2, proxy: false, scope: "server" };
93
+ }
94
+ if (isLocalDevApiProxy()) {
95
+ return { baseUrl: "/api", missing: false, proxy: true, scope: "client" };
96
+ }
97
+ const apiUrl = publicUrl || authBaseURL;
98
+ return { baseUrl: stripTrailingSlashes(apiUrl), missing: !apiUrl, proxy: false, scope: "client" };
99
+ }
100
+ export function warnMissingLtApiUrl(scope) {
101
+ warnOnce(`missing-api-url:${scope}`, MISSING_API_URL_WARNING[scope]);
102
+ }
71
103
  export function buildLtApiUrl(path) {
72
104
  try {
73
- const runtimeConfig = useRuntimeConfig();
74
- const publicUrl = runtimeConfig.public.apiUrl || "";
75
- const authBaseURL = runtimeConfig.public?.ltExtensions?.auth?.baseURL || "";
76
- if (import.meta.server) {
77
- const apiUrl2 = runtimeConfig.apiUrl || publicUrl || authBaseURL;
78
- if (!apiUrl2) {
79
- console.warn("[LtExtensions] No API URL configured. Set NUXT_API_URL or NUXT_PUBLIC_API_URL.");
80
- }
81
- return `${(apiUrl2 || "").replace(/\/+$/, "")}${path}`;
82
- }
83
- if (isLocalDevApiProxy()) {
84
- return `/api${path}`;
85
- }
86
- const apiUrl = publicUrl || authBaseURL;
87
- if (!apiUrl) {
88
- console.warn("[LtExtensions] No API URL configured. Set NUXT_PUBLIC_API_URL.");
105
+ const { baseUrl, missing, scope } = resolveLtApiBaseUrl();
106
+ if (missing) {
107
+ warnMissingLtApiUrl(scope);
89
108
  }
90
- return `${(apiUrl || "").replace(/\/+$/, "")}${path}`;
109
+ return `${baseUrl}${path}`;
91
110
  } catch {
92
111
  return path;
93
112
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Config Check Plugin
3
+ *
4
+ * Reports a missing API URL once at app init instead of waiting for whichever
5
+ * code path happens to build the first URL. The condition is read from
6
+ * `runtimeConfig` and is fixed for the module instance, so it belongs in a
7
+ * single boot-time check rather than in the hot `buildLtApiUrl()` path.
8
+ *
9
+ * Universal on purpose: SSR and the browser resolve the base URL from different
10
+ * keys (`NUXT_API_URL` vs `NUXT_PUBLIC_API_URL`) and can be misconfigured
11
+ * independently. On the server the plugin runs per request, but
12
+ * `warnMissingLtApiUrl()` is one-shot, so the warning still appears exactly once
13
+ * per process — and `buildLtApiUrl()` shares the same key, so it never repeats.
14
+ */
15
+ import type { ObjectPlugin, Plugin } from '#app';
16
+ declare const _default: ObjectPlugin & Plugin;
17
+ export default _default;
@@ -0,0 +1,11 @@
1
+ import { defineNuxtPlugin } from "#imports";
2
+ import { resolveLtApiBaseUrl, warnMissingLtApiUrl } from "../lib/auth-state.js";
3
+ export default defineNuxtPlugin(() => {
4
+ try {
5
+ const { missing, scope } = resolveLtApiBaseUrl();
6
+ if (missing) {
7
+ warnMissingLtApiUrl(scope);
8
+ }
9
+ } catch {
10
+ }
11
+ });
@@ -33,7 +33,7 @@ export interface LtAuthState {
33
33
  * All options have sensible defaults for nest-server compatibility
34
34
  */
35
35
  export interface LtAuthClientConfig {
36
- /** API base URL (default: from env or http://localhost:3000) */
36
+ /** API base URL (default: `''` — empty means requests use the current app origin; production passes an explicit URL from `NUXT_PUBLIC_API_URL`. No implicit `localhost` fallback) */
37
37
  baseURL?: string;
38
38
  /** Auth API base path (default: '/iam' - must match nest-server betterAuth.basePath) */
39
39
  basePath?: string;
@@ -56,7 +56,7 @@ export interface LtAuthCookieNamesOptions {
56
56
  export interface LtAuthModuleOptions {
57
57
  /** Auth API base path (default: '/iam' - must match nest-server betterAuth.basePath) */
58
58
  basePath?: string;
59
- /** API base URL (default: from env or http://localhost:3000) */
59
+ /** API base URL (default: `''` — resolved at runtime from `NUXT_PUBLIC_API_URL` / `NUXT_API_URL`; no implicit `localhost` fallback, so an unset URL keeps API paths relative to the app origin) */
60
60
  baseURL?: string;
61
61
  /** Override the cookie names used for auth state and JWT storage */
62
62
  cookieNames?: LtAuthCookieNamesOptions;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nuxt-extensions",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
4
4
  "description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects",
5
5
  "repository": {
6
6
  "type": "git",