@lenne.tech/nuxt-extensions 1.24.0 → 1.25.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
@@ -27,7 +27,7 @@ src/
27
27
  |-----------|---------|
28
28
  | `useLtAuth()` | Authentication state, login, logout, session management, password change + reset |
29
29
  | `useLtAuthClient()` | Raw Better Auth client access (`twoFactor.*`, `admin.*`, `passkey`) |
30
- | `useSystemSetup()` | First-admin setup flow |
30
+ | `useSystemSetup()` | First-admin setup flow: status, HTTP-setup release + setup token (nest-server >= 12.4.0) |
31
31
  | `useLtTusUpload()` | TUS resumable file uploads |
32
32
  | `useLtFile()` | File utilities (size formatting, URLs) |
33
33
  | `useLtShare()` | Web Share API with clipboard fallback |
@@ -311,7 +311,8 @@ This is a consequence of client-side hashing, not a bug in either backend, and t
311
311
  ### `admin.*` is passed through UNHASHED — an armed trap, not a live one
312
312
 
313
313
  `admin.createUser` (`password`) and `admin.setUserPassword` (`newPassword`) are the only
314
- credential-carrying methods this library does **not** hash.
314
+ passwords this library does **not** hash. (The setup token of `useSystemSetup().initSetup()` is
315
+ unhashed too, by design — see "First Admin Setup" below.)
315
316
 
316
317
  Today that is harmless: `@lenne.tech/nest-server` does not register better-auth's `admin()`
317
318
  plugin and has no option to enable it, so those routes 404. But `auth.enableAdmin` defaults to
@@ -333,6 +334,28 @@ replaces ONE field and forwards the rest — `{ ...params, password: hashed }`.
333
334
  shipped defects), and never `{ password: hashed, ...params }`, which looks almost identical and
334
335
  puts the **plaintext** back on the wire.
335
336
 
337
+ ## First Admin Setup: the HTTP init is closed on deployed stages
338
+
339
+ Since nest-server 12.4.0 the anonymous `POST /system-setup/init` is open only in `local`, `ci` and
340
+ `e2e`. A deployed API refuses it with 403 (`LTNS_0053`) unless `systemSetup.httpInit` releases it,
341
+ optionally behind a setup token (`LTNS_0054` when wrong). `GET /system-setup/status` reports
342
+ `httpInitEnabled` and `httpInitTokenRequired`; `useSystemSetup()` exposes both.
343
+
344
+ - **The middleware does not redirect when `httpInitEnabled === false`.** That is deliberate, not a
345
+ bug: a redirect would hold every visitor on a page whose only action fails, and the login would be
346
+ unreachable. The setup page itself must show the notice ("first admin comes from the
347
+ configuration") instead of the form.
348
+ - **The token is a header (`X-System-Setup-Token`), unhashed, never a body field.** Unlike the
349
+ passwords, it is not hashed: the server compares it as given. Do not add it to the SHA256
350
+ wrappers.
351
+ - **A missing field means an API before 12.4.0**, which accepts the HTTP setup: `httpInitEnabled`
352
+ reads `true`. The server's 403 is the boundary, the flag is UX. Only a 200 without the field
353
+ counts as old; a failed status read leaves both values unchanged (`null` initially).
354
+ - **After a 403 from `initSetup()` the composable re-reads the status** before rethrowing, so the
355
+ page switches to the notice or the token field when the API was restarted with another
356
+ configuration. Pass the error itself to `translateError(error)`: the `#LTNS_` code is in the
357
+ body, `error.message` holds only the transport line.
358
+
336
359
  ## Form Label Repair
337
360
 
338
361
  **The defect:** Nuxt UI's `FormField` derives the label's `for` and the control's `id` from one
@@ -433,6 +456,10 @@ fields at `app:beforeMount` and restores them at `app:mounted`, dispatching a sy
433
456
  relative `extends` points at nothing, and `oxlint --type-aware` then stops every vendored project's
434
457
  `check` with `Invalid tsconfig`. Nitro code is typechecked through the root `tsconfig.server.json`.
435
458
  No gate here runs in vendor mode, so `test/vendored-core.test.ts` holds this.
459
+ - **nuxt-base-starter lints `app/core/**` with a subset of the plugins in `.oxlintrc.json`, never
460
+ more**, and `lint` here runs with `--deny-warnings`. A plugin only the starter applies reports its
461
+ warnings in every generated project after the release; the 1.24.0 smoke test found three. A plugin
462
+ the starter wants for `app/core/**` is added here first.
436
463
  - **The `nuxt/schema` blocks in `runtime/types/module.ts` augment `PublicRuntimeConfig` and nothing
437
464
  else.** Vendoring turns them into project source, where they cause TS2310, and the cli removes a
438
465
  block only while it holds that one interface. Type server-only config where it is read.
package/README.md CHANGED
@@ -567,6 +567,51 @@ against the value it receives, and because this library hashes first, that value
567
567
  64-character hex string — the check passes on every path. The form is the only place the rule can
568
568
  live.
569
569
 
570
+ ### First Admin Setup
571
+
572
+ With `auth.systemSetup.enabled`, a global middleware sends visitors to `setupPath` while the API
573
+ has no user yet, and `useSystemSetup()` creates the first admin.
574
+
575
+ Since `@lenne.tech/nest-server` 12.4.0 the anonymous `POST /system-setup/init` is open only in
576
+ `local`, `ci` and `e2e`. A deployed API refuses it with 403 (`LTNS_0053`) unless the deployment
577
+ releases it (`systemSetup.httpInit`), optionally behind a setup token. A deployment usually creates
578
+ its first admin from its configuration instead (`systemSetup.initialAdmin`).
579
+ `GET /system-setup/status` reports which applies, and the composable exposes it:
580
+
581
+ ```vue
582
+ <script setup lang="ts">
583
+ const { httpInitEnabled, httpInitTokenRequired, initSetup } = useSystemSetup();
584
+ const { translateError } = useLtErrorTranslation();
585
+ const toast = useToast();
586
+
587
+ async function onSubmit(data: { email: string; name: string; password: string; token?: string }) {
588
+ try {
589
+ // `token` goes out as the X-System-Setup-Token header, unhashed; omit it when not required.
590
+ await initSetup(data);
591
+ } catch (error) {
592
+ // Pass the error itself: the #LTNS_ code is in the response body, not in `error.message`.
593
+ toast.add({ color: 'error', description: translateError(error), title: 'Setup failed' });
594
+ }
595
+ }
596
+ </script>
597
+
598
+ <template>
599
+ <!-- `false`: the deployment creates its first admin from its configuration — no form. -->
600
+ <SetupNotice v-if="httpInitEnabled === false" />
601
+ <SetupForm v-else :with-token="httpInitTokenRequired === true" @submit="onSubmit" />
602
+ </template>
603
+ ```
604
+
605
+ - **Without the HTTP setup the middleware redirects nowhere.** A redirect would hold every visitor
606
+ on a page whose only action fails. The setup page stays reachable, so it must show the notice
607
+ itself.
608
+ - **An API before 12.4.0** reports neither field and accepts the HTTP setup, so `httpInitEnabled`
609
+ reads `true` and `httpInitTokenRequired` `false` — the behaviour before this release.
610
+ - **After a 403 from `initSetup()`** the composable re-reads the status before rethrowing, so a page
611
+ whose API was restarted with another configuration switches to the notice (`LTNS_0053`) or to the
612
+ token field (`LTNS_0054`) without a reload.
613
+ - `null` means the status was not read yet, or reading it failed.
614
+
570
615
  ### Custom Better Auth Plugins
571
616
 
572
617
  You can extend the auth client with additional [Better Auth plugins](https://www.better-auth.com/docs/plugins):
@@ -975,7 +1020,7 @@ The package works **with or without** `@nuxtjs/i18n`:
975
1020
  | `useLtTusUpload()` | TUS protocol file uploads with pause/resume |
976
1021
  | `useLtFile()` | File utilities (size formatting, URLs) |
977
1022
  | `useLtShare()` | Web Share API with clipboard fallback |
978
- | `useSystemSetup()` | System setup flow for initial admin user creation |
1023
+ | `useSystemSetup()` | First admin creation: setup status, HTTP-setup release + token (nest-server >= 12.4.0) |
979
1024
  | `useLtAi()` | One-shot `prompt()` + streaming `promptStream()` for the nest-server AI module |
980
1025
  | `useLtAiChat()` | Multi-turn chat state, streaming, budget summary, confirmation gate, `maxMessages` cap |
981
1026
  | `useLtAiConnections()` | User self-service connection list + `select()` |
package/dist/module.d.mts CHANGED
@@ -3,7 +3,7 @@ import { LtExtensionsModuleOptions } from '../dist/runtime/types/index.js';
3
3
  export * from '../dist/runtime/types/index.js';
4
4
 
5
5
  declare const name = "@lenne.tech/nuxt-extensions";
6
- declare const version = "1.24.0";
6
+ declare const version = "1.25.0";
7
7
  declare const configKey = "ltExtensions";
8
8
  declare const DEFAULT_LT_AUTH_STATE_COOKIE = "lt-auth-state";
9
9
  declare const DEFAULT_LT_JWT_TOKEN_COOKIE = "lt-jwt-token";
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "configKey": "ltExtensions",
3
3
  "name": "@lenne.tech/nuxt-extensions",
4
- "version": "1.24.0",
4
+ "version": "1.25.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -3,7 +3,7 @@ import { pathToFileURL } from 'node:url';
3
3
  import { SECURITY_TXT_DEFAULT_VALID_DAYS, SECURITY_TXT_DEFAULT_LANGUAGES, SECURITY_TXT_DEFAULT_CONTACT, SECURITY_TXT_PATH } from '../dist/runtime/server/lib/security-txt.js';
4
4
 
5
5
  const name = "@lenne.tech/nuxt-extensions";
6
- const version = "1.24.0";
6
+ const version = "1.25.0";
7
7
  const configKey = "ltExtensions";
8
8
  const DEFAULT_LT_AUTH_STATE_COOKIE = "lt-auth-state";
9
9
  const DEFAULT_LT_JWT_TOKEN_COOKIE = "lt-jwt-token";
@@ -12,13 +12,27 @@ import type { ComputedRef } from 'vue';
12
12
  export interface UseSystemSetupReturn {
13
13
  /** Whether the system needs initial setup (null = not checked yet) */
14
14
  needsSetup: ComputedRef<boolean | null>;
15
+ /**
16
+ * Whether the API accepts the setup over HTTP (null = not checked yet). `false` means the
17
+ * deployment creates its first admin from its configuration, and `initSetup()` would be
18
+ * refused with 403 (`LTNS_0053`): show a notice instead of the form. An API before
19
+ * nest-server 12.4.0 does not report it and accepts the HTTP setup, so it reads as `true`.
20
+ */
21
+ httpInitEnabled: ComputedRef<boolean | null>;
22
+ /** Whether `initSetup()` needs a `token` (null = not checked yet). Only an explicit `true` counts. */
23
+ httpInitTokenRequired: ComputedRef<boolean | null>;
15
24
  /** Check setup status from the backend */
16
25
  checkSetupStatus: () => Promise<boolean>;
17
- /** Initialize the system with the first admin user */
26
+ /**
27
+ * Initialize the system with the first admin user. `token` is the setup token, required when
28
+ * `httpInitTokenRequired` is true. A refusal is rethrown unchanged, so `useLtErrorTranslation()`
29
+ * reads its code (`LTNS_0053` HTTP setup disabled, `LTNS_0054` wrong token) from the body.
30
+ */
18
31
  initSetup: (params: {
19
32
  email: string;
20
33
  password: string;
21
34
  name: string;
35
+ token?: string;
22
36
  }) => Promise<void>;
23
37
  }
24
38
  /**
@@ -26,11 +40,11 @@ export interface UseSystemSetupReturn {
26
40
  *
27
41
  * @example
28
42
  * ```typescript
29
- * const { needsSetup, checkSetupStatus, initSetup } = useSystemSetup();
43
+ * const { needsSetup, httpInitEnabled, httpInitTokenRequired, checkSetupStatus, initSetup } = useSystemSetup();
30
44
  *
31
45
  * await checkSetupStatus();
32
- * if (needsSetup.value) {
33
- * await initSetup({ email: 'admin@example.com', password: 'secret', name: 'Admin' });
46
+ * if (needsSetup.value && httpInitEnabled.value) {
47
+ * await initSetup({ email: 'admin@example.com', password: 'secret', name: 'Admin', token });
34
48
  * }
35
49
  * ```
36
50
  */
@@ -8,9 +8,17 @@ function isApiUrlMissing() {
8
8
  return true;
9
9
  }
10
10
  }
11
+ const SETUP_TOKEN_HEADER = "X-System-Setup-Token";
12
+ function isRefusal(error) {
13
+ const { status, statusCode } = error ?? {};
14
+ return (statusCode ?? status) === 403;
15
+ }
11
16
  export function useSystemSetup() {
12
17
  const needsSetupState = useState("lt-needs-setup", () => null);
18
+ const httpInitState = useState("lt-system-setup-http-init", () => null);
13
19
  const needsSetup = computed(() => needsSetupState.value);
20
+ const httpInitEnabled = computed(() => httpInitState.value?.enabled ?? null);
21
+ const httpInitTokenRequired = computed(() => httpInitState.value?.tokenRequired ?? null);
14
22
  async function checkSetupStatus() {
15
23
  if (import.meta.server && isApiUrlMissing()) {
16
24
  warnMissingLtApiUrl("server");
@@ -19,6 +27,12 @@ export function useSystemSetup() {
19
27
  try {
20
28
  const url = buildLtApiUrl("/system-setup/status");
21
29
  const data = await $fetch(url);
30
+ httpInitState.value = {
31
+ // Missing or malformed reads as released: an API before 12.4.0 accepts the HTTP setup,
32
+ // and the server's 403 is the boundary, not this flag.
33
+ enabled: typeof data.httpInitEnabled === "boolean" ? data.httpInitEnabled : true,
34
+ tokenRequired: data.httpInitTokenRequired === true
35
+ };
22
36
  needsSetupState.value = data.needsSetup;
23
37
  return data.needsSetup;
24
38
  } catch {
@@ -29,18 +43,29 @@ export function useSystemSetup() {
29
43
  async function initSetup(params) {
30
44
  const url = buildLtApiUrl("/system-setup/init");
31
45
  const hashedPassword = await ltSha256(params.password);
32
- await $fetch(url, {
33
- method: "POST",
34
- body: {
35
- email: params.email,
36
- password: hashedPassword,
37
- name: params.name
46
+ const token = params.token?.trim();
47
+ try {
48
+ await $fetch(url, {
49
+ method: "POST",
50
+ body: {
51
+ email: params.email,
52
+ password: hashedPassword,
53
+ name: params.name
54
+ },
55
+ ...token ? { headers: { [SETUP_TOKEN_HEADER]: token } } : {}
56
+ });
57
+ } catch (error) {
58
+ if (isRefusal(error)) {
59
+ await checkSetupStatus();
38
60
  }
39
- });
61
+ throw error;
62
+ }
40
63
  needsSetupState.value = false;
41
64
  }
42
65
  return {
43
66
  needsSetup,
67
+ httpInitEnabled,
68
+ httpInitTokenRequired,
44
69
  checkSetupStatus,
45
70
  initSetup
46
71
  };
@@ -74,15 +74,15 @@ function connectHub(key, hub) {
74
74
  hub.close = openLtSyncHints({
75
75
  models: models.split(","),
76
76
  onConnect: () => {
77
- for (const subscriber of [...hub.subscribers]) subscriber.onConnect();
77
+ for (const subscriber of Array.from(hub.subscribers)) subscriber.onConnect();
78
78
  },
79
79
  onHint: (model) => {
80
- for (const subscriber of [...hub.subscribers]) if (subscriber.model === model) subscriber.onHint();
80
+ for (const subscriber of Array.from(hub.subscribers)) if (subscriber.model === model) subscriber.onHint();
81
81
  },
82
82
  onStop: (reason) => {
83
83
  hub.close = void 0;
84
84
  hub.models = "";
85
- for (const subscriber of [...hub.subscribers]) subscriber.onStop?.(reason);
85
+ for (const subscriber of Array.from(hub.subscribers)) subscriber.onStop?.(reason);
86
86
  },
87
87
  tenantId: key || void 0
88
88
  });
@@ -5,8 +5,15 @@
5
5
  * needs initial setup (no admin user exists yet).
6
6
  *
7
7
  * - needsSetup === null -> calls checkSetupStatus()
8
- * - needsSetup === true AND route != setupPath -> redirect to setupPath
8
+ * - needsSetup === true AND HTTP init released AND route != setupPath -> redirect to setupPath
9
+ * - needsSetup === true AND HTTP init NOT released -> no redirect
9
10
  * - needsSetup === false AND route === setupPath -> redirect to loginPath
11
+ *
12
+ * Without the HTTP init (nest-server >= 12.4.0 closes it outside `local`, `ci` and `e2e`), the
13
+ * API refuses `POST /system-setup/init` with 403 and the deployment creates its first admin from
14
+ * its configuration. A redirect would then hold every visitor on a page whose only action fails,
15
+ * with the login out of reach. So nothing is redirected; the setup page stays reachable and shows
16
+ * its notice there (`useSystemSetup().httpInitEnabled === false`).
10
17
  */
11
18
  import type { RouteMiddleware } from '#app';
12
19
  declare const _default: RouteMiddleware;
@@ -5,12 +5,12 @@ export default defineNuxtRouteMiddleware(async (to) => {
5
5
  const authConfig = runtimeConfig.public?.ltExtensions?.auth;
6
6
  const setupPath = authConfig?.systemSetup?.setupPath || "/auth/setup";
7
7
  const loginPath = authConfig?.loginPath || "/auth/login";
8
- const { needsSetup, checkSetupStatus } = useSystemSetup();
8
+ const { needsSetup, httpInitEnabled, checkSetupStatus } = useSystemSetup();
9
9
  try {
10
10
  if (needsSetup.value === null) {
11
11
  await checkSetupStatus();
12
12
  }
13
- if (needsSetup.value === true && to.path !== setupPath) {
13
+ if (needsSetup.value === true && httpInitEnabled.value !== false && to.path !== setupPath) {
14
14
  return navigateTo(setupPath);
15
15
  }
16
16
  if (needsSetup.value === false && to.path === setupPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nuxt-extensions",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,8 +60,8 @@
60
60
  "dev:build": "nuxt build playground",
61
61
  "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
62
62
  "build": "nuxt-module-build build",
63
- "lint": "oxlint --type-aware src/ scripts/",
64
- "lint:fix": "oxlint --type-aware --fix src/ scripts/",
63
+ "lint": "oxlint --type-aware --deny-warnings src/ scripts/",
64
+ "lint:fix": "oxlint --type-aware --deny-warnings --fix src/ scripts/",
65
65
  "format": "oxfmt --write src/",
66
66
  "format:check": "oxfmt --check src/",
67
67
  "version:sync": "node scripts/sync-module-version.mjs",