@lenne.tech/nuxt-extensions 1.22.0 → 1.24.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 +36 -2
- package/README.md +128 -17
- package/dist/module.d.mts +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +38 -2
- package/dist/runtime/composables/index.d.ts +1 -0
- package/dist/runtime/composables/index.js +1 -0
- package/dist/runtime/composables/use-lt-ai-status.d.ts +16 -0
- package/dist/runtime/composables/use-lt-ai-status.js +57 -0
- package/dist/runtime/lib/auth-client.d.ts +25 -1
- package/dist/runtime/lib/passkey-stub.d.ts +4 -0
- package/dist/runtime/lib/passkey-stub.js +4 -0
- package/dist/runtime/server/lib/security-txt.d.ts +83 -0
- package/dist/runtime/server/lib/security-txt.js +87 -0
- package/dist/runtime/server/routes/security-txt.d.ts +9 -0
- package/dist/runtime/server/routes/security-txt.js +19 -0
- package/dist/runtime/types/ai.d.ts +21 -0
- package/dist/runtime/types/index.d.ts +2 -2
- package/dist/runtime/types/module.d.ts +63 -0
- package/package.json +6 -6
- package/dist/runtime/server/tsconfig.json +0 -3
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/ #
|
|
16
|
+
│ ├── server/ # Nitro code: the opt-in security.txt route (auth talks to the backend directly, no proxy)
|
|
17
17
|
│ ├── testing/ # Playwright test helpers
|
|
18
18
|
│ ├── types/ # TypeScript type definitions
|
|
19
19
|
│ └── utils/ # Auto-imported utility functions
|
|
@@ -41,6 +41,7 @@ src/
|
|
|
41
41
|
| `useLtAiChat()` | Multi-turn chat: streaming, budget summary, confirmation gate, `stop()`/`clear()`, optional `maxMessages` cap, auto-stop on unmount |
|
|
42
42
|
| `useLtAiConnections()` | User-facing list of available connections + `select()` |
|
|
43
43
|
| `useLtAiSettings()` | The user's own AI settings: MCP confirmation switch (`GET/PUT /ai/settings`, `enforced` → show disabled) |
|
|
44
|
+
| `useLtAiStatus()` | Whether the API's AI module is on (`GET /ai/status`, public; 404 = off): `enabled`, `mcp`, shared `useState`, one request per app start, fails closed |
|
|
44
45
|
| `useLtAiUsage()` | Full token/prompt usage breakdown (`GET /ai/usage`) |
|
|
45
46
|
| `useLtAiPrompts()` | User-facing CRUD for re-usable prompt snippets ("Vorlagen", `scope: 'user'` / `'tenant'`) |
|
|
46
47
|
| `useLtAiPlaceholders()` | Loads the backend's `{{placeholder}}` registry for editor sidebars |
|
|
@@ -95,6 +96,10 @@ export default defineNuxtConfig({
|
|
|
95
96
|
enabled: true, // keep text typed before hydration instead of losing it
|
|
96
97
|
maxRestoreMs: 1500, // how long to keep repairing deferred subtrees
|
|
97
98
|
},
|
|
99
|
+
securityTxt: {
|
|
100
|
+
enabled: false, // opt-in: /.well-known/security.txt (RFC 9116)
|
|
101
|
+
contact: 'mailto:security@lenne.tech',
|
|
102
|
+
},
|
|
98
103
|
},
|
|
99
104
|
});
|
|
100
105
|
```
|
|
@@ -403,6 +408,35 @@ fields at `app:beforeMount` and restores them at `app:mounted`, dispatching a sy
|
|
|
403
408
|
takes over.
|
|
404
409
|
- Opt out with `ltExtensions.preHydrationInput.enabled: false`.
|
|
405
410
|
|
|
411
|
+
## security.txt (RFC 9116)
|
|
412
|
+
|
|
413
|
+
`ltExtensions.securityTxt.enabled: true` serves `/.well-known/security.txt`. Contact defaults to
|
|
414
|
+
`mailto:security@lenne.tech`. Builder: `runtime/server/lib/security-txt.ts` (pure, tested in
|
|
415
|
+
`test/security-txt.test.ts`). Route: `runtime/server/routes/security-txt.ts`.
|
|
416
|
+
|
|
417
|
+
- **Do not replace it with a static file in `public/`.** `Expires` is computed per request so it
|
|
418
|
+
never runs out between deployments. A static file expires silently, and scanners drop it.
|
|
419
|
+
- **Do not register it with a method.** `addServerHandler` without `method` answers HEAD too;
|
|
420
|
+
scanners probe with HEAD.
|
|
421
|
+
- **Values live in the server-only runtime config** (`runtimeConfig.ltExtensions.securityTxt`), so
|
|
422
|
+
`NUXT_LT_EXTENSIONS_SECURITY_TXT_*` overrides them per stage. `enabled` is build-time only.
|
|
423
|
+
- **`Canonical` from the request host only via the `hosts` allowlist.** The host is a request
|
|
424
|
+
header; anything not listed must never reach the file.
|
|
425
|
+
- **No list defaults in `defaultOptions`.** `defineNuxtModule` merges them with `defu`, which
|
|
426
|
+
concatenates arrays: a default `['de', 'en']` turned a project's `['en']` into `en, de, en`. Set
|
|
427
|
+
list defaults where the option is read. `test/security-txt.test.ts` runs the real merge.
|
|
428
|
+
- **A contact that is not a `mailto:`, `https://` or `tel:` URI counts as blank**, so a bare address
|
|
429
|
+
ends in a 404 rather than a file scanners reject. If a project reports a 404 with the option on,
|
|
430
|
+
check the contact first.
|
|
431
|
+
- For a customer project, ask whether reports go to lenne.Tech or the customer before enabling it.
|
|
432
|
+
- **No `tsconfig.json` under `src/runtime`.** Vendoring copies that tree one level deeper, so a
|
|
433
|
+
relative `extends` points at nothing, and `oxlint --type-aware` then stops every vendored project's
|
|
434
|
+
`check` with `Invalid tsconfig`. Nitro code is typechecked through the root `tsconfig.server.json`.
|
|
435
|
+
No gate here runs in vendor mode, so `test/vendored-core.test.ts` holds this.
|
|
436
|
+
- **The `nuxt/schema` blocks in `runtime/types/module.ts` augment `PublicRuntimeConfig` and nothing
|
|
437
|
+
else.** Vendoring turns them into project source, where they cause TS2310, and the cli removes a
|
|
438
|
+
block only while it holds that one interface. Type server-only config where it is read.
|
|
439
|
+
|
|
406
440
|
## Development Rules
|
|
407
441
|
|
|
408
442
|
1. **ALWAYS read source code** in `dist/runtime/` to understand available composables and components
|
|
@@ -412,7 +446,7 @@ fields at `app:beforeMount` and restores them at `app:mounted`, dispatching a sy
|
|
|
412
446
|
Handling" below).
|
|
413
447
|
3. **Check existing composables** before creating new ones — this module may already provide what you need
|
|
414
448
|
4. **Peer dependencies** (`better-auth`, `@better-auth/passkey`, `tus-js-client`) must be installed in the consuming project.
|
|
415
|
-
`better-auth` and `@better-auth/passkey` are pinned to **`>=1.7.
|
|
449
|
+
`better-auth` and `@better-auth/passkey` are pinned to **`>=1.7.16 <1.8.0`** — deliberately not a caret.
|
|
416
450
|
**They move in lock-step with `@lenne.tech/nest-server`.** better-auth is one protocol with two ends;
|
|
417
451
|
this module is the client end, nest-server the server end, and both declare the *same* narrow range.
|
|
418
452
|
Raising it means raising it in **both** framework repos and pinning the new version in **both** starters,
|
package/README.md
CHANGED
|
@@ -29,8 +29,8 @@ Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.t
|
|
|
29
29
|
|---|---|---|
|
|
30
30
|
| Node | `>= 22` | |
|
|
31
31
|
| Nuxt | `^4.0.0` | |
|
|
32
|
-
| `better-auth` | `>=1.7.
|
|
33
|
-
| `@better-auth/passkey` | `>=1.7.
|
|
32
|
+
| `better-auth` | `>=1.7.16 <1.8.0` | **required peer** — pin it, see below |
|
|
33
|
+
| `@better-auth/passkey` | `>=1.7.16 <1.8.0` | optional peer, passkey/WebAuthn only |
|
|
34
34
|
| `tus-js-client` | `>=4.0.0` | optional peer, resumable uploads only |
|
|
35
35
|
| `@lenne.tech/nest-server` | `>= 11.37.0` | **only if you use the backend** — see the lock-step rule |
|
|
36
36
|
|
|
@@ -38,7 +38,7 @@ Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.t
|
|
|
38
38
|
better-auth is one protocol with two ends. This module is the client end and
|
|
39
39
|
`@lenne.tech/nest-server` is the server end, and they must resolve the **same**
|
|
40
40
|
better-auth minor. better-auth breaks in *minor* releases — 1.7 removed subpath
|
|
41
|
-
exports and changed the 2FA response shape — so a caret (`^1.7.
|
|
41
|
+
exports and changed the 2FA response shape — so a caret (`^1.7.16`) would re-open the
|
|
42
42
|
hole at 1.8. If the api and the app drift apart, 2FA activation fails at runtime with a
|
|
43
43
|
generic client error while **both** projects' checks stay green: each is internally
|
|
44
44
|
consistent, and only the assembled workspace has both halves.
|
|
@@ -46,21 +46,35 @@ consistent, and only the assembled workspace has both halves.
|
|
|
46
46
|
Raising better-auth therefore means raising it in `@lenne.tech/nest-server` **and**
|
|
47
47
|
`@lenne.tech/nuxt-extensions` in one step.
|
|
48
48
|
|
|
49
|
-
**Why the floor is 1.7.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
(
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
**Why the floor is 1.7.16:** better-auth 1.7.16 closes twelve advisories published on
|
|
50
|
+
2026-11-03. Among those that reach a typical lt project: a social sign-in can treat an unverified
|
|
51
|
+
provider email as verified
|
|
52
|
+
([GHSA-9hm7-xhfp-cx58](https://github.com/better-auth/better-auth/security/advisories/GHSA-9hm7-xhfp-cx58)),
|
|
53
|
+
revoked sessions can stay valid with secondary storage or the cookie cache
|
|
54
|
+
([GHSA-fvxx-76fc-893x](https://github.com/better-auth/better-auth/security/advisories/GHSA-fvxx-76fc-893x)),
|
|
55
|
+
email-change links stay valid after the address changes hands
|
|
56
|
+
([GHSA-hwq2-v29r-p69r](https://github.com/better-auth/better-auth/security/advisories/GHSA-hwq2-v29r-p69r)),
|
|
57
|
+
and passkey verification does not enforce origin or user verification
|
|
58
|
+
([GHSA-gv75-j3v8-3crm](https://github.com/better-auth/better-auth/security/advisories/GHSA-gv75-j3v8-3crm)).
|
|
59
|
+
It also pins better-call 1.4.5, which closes
|
|
60
|
+
[GHSA-23w8-wx9r-vm57](https://github.com/better-auth/better-call/security/advisories/GHSA-23w8-wx9r-vm57)
|
|
61
|
+
(high, shared router context). That one is a second layer only: better-auth's own handler builds a
|
|
62
|
+
fresh router per request, so the flaw is not reachable through it. Older flaws stay behind the
|
|
63
|
+
floor too: before
|
|
64
|
+
1.7.12 better-auth verifies JWTs with the algorithm named in the token's own header
|
|
65
|
+
([#11925](https://github.com/better-auth/better-auth/pull/11925)), and below 1.7.7 the OAuth
|
|
66
|
+
state can be used as a magic link to sign in as another user
|
|
67
|
+
([GHSA-965c-763c-88jm](https://github.com/better-auth/better-auth/security/advisories/GHSA-965c-763c-88jm),
|
|
68
|
+
critical). All of these are in the server end, so your API must run 1.7.16 too
|
|
69
|
+
(`@lenne.tech/nest-server` 12.3.1 declares the same range), and the pin below keeps both halves
|
|
70
|
+
on it.
|
|
57
71
|
|
|
58
72
|
## Installation
|
|
59
73
|
|
|
60
74
|
```bash
|
|
61
|
-
npm install @lenne.tech/nuxt-extensions better-auth@1.7.
|
|
75
|
+
npm install @lenne.tech/nuxt-extensions better-auth@1.7.16
|
|
62
76
|
# Optional: For passkey support
|
|
63
|
-
npm install @better-auth/passkey@1.7.
|
|
77
|
+
npm install @better-auth/passkey@1.7.16
|
|
64
78
|
# Optional: For TUS file uploads
|
|
65
79
|
npm install tus-js-client
|
|
66
80
|
```
|
|
@@ -70,8 +84,9 @@ In a fullstack monorepo, pin it workspace-wide so the api and the app cannot dri
|
|
|
70
84
|
```yaml
|
|
71
85
|
# pnpm-workspace.yaml
|
|
72
86
|
overrides:
|
|
73
|
-
better-auth: 1.7.
|
|
74
|
-
'@better-auth/passkey': 1.7.
|
|
87
|
+
better-auth: 1.7.16
|
|
88
|
+
'@better-auth/passkey': 1.7.16
|
|
89
|
+
'@better-auth/core': 1.7.16 # the api of nest-server-starter projects depends on it directly
|
|
75
90
|
```
|
|
76
91
|
|
|
77
92
|
## Features
|
|
@@ -82,6 +97,7 @@ overrides:
|
|
|
82
97
|
- **Offline Sync** - Read and write offline, reconcile with the nest-server sync module (opt-in)
|
|
83
98
|
- **Transition Components** - Ready-to-use Vue transition wrappers
|
|
84
99
|
- **i18n Support** - English and German translations (works without i18n too)
|
|
100
|
+
- **security.txt** - Opt-in RFC 9116 file that never expires between deployments
|
|
85
101
|
- **Auto-imports** - All composables and components are auto-imported
|
|
86
102
|
- **Pre-hydration input preservation** - text typed before hydration is kept, not erased
|
|
87
103
|
- **Form label repair** - restores `<label for>` associations that hydration breaks
|
|
@@ -198,6 +214,12 @@ export default defineNuxtConfig({
|
|
|
198
214
|
basePath: '/ai', // Must match the nest-server AI controller
|
|
199
215
|
},
|
|
200
216
|
|
|
217
|
+
// security.txt at /.well-known/security.txt (see "security.txt" below)
|
|
218
|
+
securityTxt: {
|
|
219
|
+
enabled: false, // Opt-in
|
|
220
|
+
contact: 'mailto:security@lenne.tech',
|
|
221
|
+
},
|
|
222
|
+
|
|
201
223
|
// Offline sync (opt-in, see "Offline Sync" below)
|
|
202
224
|
sync: {
|
|
203
225
|
enabled: false, // Enable useLtOfflineSync() and the sign-out cleanup
|
|
@@ -363,6 +385,61 @@ export default defineNuxtConfig({
|
|
|
363
385
|
});
|
|
364
386
|
```
|
|
365
387
|
|
|
388
|
+
## security.txt (RFC 9116)
|
|
389
|
+
|
|
390
|
+
A `security.txt` tells security researchers where to report a vulnerability. The module can serve
|
|
391
|
+
one at `/.well-known/security.txt`. It is **off by default**: only turn it on where somebody
|
|
392
|
+
actually reads the contact address. A published address that nobody answers is worse than none.
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
export default defineNuxtConfig({
|
|
396
|
+
ltExtensions: {
|
|
397
|
+
securityTxt: {
|
|
398
|
+
enabled: true,
|
|
399
|
+
// All optional:
|
|
400
|
+
contact: 'mailto:security@lenne.tech', // default; string or array, mailto:/https:/tel:
|
|
401
|
+
hosts: ['example.com', 'www.example.com'], // hostnames served without a redirect
|
|
402
|
+
preferredLanguages: ['de', 'en'], // default
|
|
403
|
+
validDays: 180, // default; clamped to 365
|
|
404
|
+
policy: '/security-policy', // a path is resolved against the site URL
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Response for `https://example.com`:
|
|
411
|
+
|
|
412
|
+
```text
|
|
413
|
+
Contact: mailto:security@lenne.tech
|
|
414
|
+
Expires: 2027-04-26T00:00:00.000Z
|
|
415
|
+
Preferred-Languages: de, en
|
|
416
|
+
Canonical: https://example.com/.well-known/security.txt
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
**For a customer project, decide where reports go.** The default sends them to lenne.Tech. If the
|
|
420
|
+
customer wants reports themselves, set their address.
|
|
421
|
+
|
|
422
|
+
How it behaves:
|
|
423
|
+
|
|
424
|
+
- **`Expires` never runs out.** The file is built per request: "now + `validDays`", cut to
|
|
425
|
+
midnight UTC. A file written once at deploy time would expire on its own, and scanners such as
|
|
426
|
+
the BSI's drop an expired file without telling anyone.
|
|
427
|
+
- **`Canonical`** names `runtimeConfig.public.siteUrl` (`NUXT_PUBLIC_SITE_URL`; the module declares
|
|
428
|
+
the key while the option is on). If the site answers under more than one hostname without a redirect (apex
|
|
429
|
+
and `www`), list them in `hosts`. A request for one of them adds a second `Canonical` line,
|
|
430
|
+
because RFC 9116 expects the URL a file was fetched from to be listed. The host comes from a
|
|
431
|
+
request header, so only hosts on that list ever reach the file.
|
|
432
|
+
- **GET and HEAD** both answer `200` with `text/plain; charset=utf-8` and `cache-control: no-store`.
|
|
433
|
+
- **Overridable per stage at runtime**, without a rebuild:
|
|
434
|
+
`NUXT_LT_EXTENSIONS_SECURITY_TXT_CONTACT`, `…_HOSTS` (`"example.com,www.example.com"`),
|
|
435
|
+
`…_PREFERRED_LANGUAGES`, `…_VALID_DAYS`, `…_POLICY`. Only `enabled` is fixed at build time.
|
|
436
|
+
- **A contact must be a URI.** `mailto:`, `https://` or `tel:`; a bare `security@example.com` counts
|
|
437
|
+
as no contact. Without a usable contact, including a blanked one, the route answers `404`, because
|
|
438
|
+
a file without `Contact` is invalid.
|
|
439
|
+
- **Never prerendered.** The route is excluded from prerendering, so `Expires` is not frozen at
|
|
440
|
+
build time. A fully static site (`nuxt generate`) has no server to answer it at all. The module
|
|
441
|
+
warns about that at build time.
|
|
442
|
+
|
|
366
443
|
## Usage
|
|
367
444
|
|
|
368
445
|
### Authentication
|
|
@@ -709,6 +786,33 @@ await load();
|
|
|
709
786
|
is private to the owner; `scope: 'tenant'` is shared across the owner's tenant. The list
|
|
710
787
|
returned by `load()` is already server-filtered to what the user is allowed to see.
|
|
711
788
|
|
|
789
|
+
### AI status
|
|
790
|
+
|
|
791
|
+
`useLtAiStatus()` says whether the API's AI module is on, so AI pages and navigation entries can
|
|
792
|
+
be shown only then. It reads the public `GET /ai/status` of nest-server: 200 `{ mcp }` while the
|
|
793
|
+
module is on, 404 while it is off.
|
|
794
|
+
|
|
795
|
+
```typescript
|
|
796
|
+
// middleware/ai.ts
|
|
797
|
+
export default defineNuxtRouteMiddleware(async () => {
|
|
798
|
+
const status = useLtAiStatus();
|
|
799
|
+
await status.load();
|
|
800
|
+
if (!status.enabled.value) {
|
|
801
|
+
return navigateTo('/app');
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
- One request per app start. The state is shared (`useState`) and travels from SSR to the
|
|
807
|
+
client, and callers that load at the same time share one request. `load({ force: true })` asks
|
|
808
|
+
again.
|
|
809
|
+
- Sent without credentials; the endpoint is public.
|
|
810
|
+
- Fails closed. A 404 means "AI is off" and sets no `error`. A network error, a timeout (5 s),
|
|
811
|
+
any other status or a malformed body leaves `enabled` false and sets `error`.
|
|
812
|
+
- `GET /ai/status` exists from nest-server 12.3.1. An older API answers 404 as well, so AI reads
|
|
813
|
+
as off there. Gate on this only once your API runs 12.3.1 or later.
|
|
814
|
+
- `mcp` says whether the API serves MCP. Show the MCP confirmation switch below only then.
|
|
815
|
+
|
|
712
816
|
### MCP confirmation switch
|
|
713
817
|
|
|
714
818
|
Over MCP (Claude Desktop, Claude Code and other clients), nest-server 12.3.0 or later asks the
|
|
@@ -732,14 +836,19 @@ async function toggle(value: boolean) {
|
|
|
732
836
|
|
|
733
837
|
<template>
|
|
734
838
|
<USwitch
|
|
735
|
-
|
|
736
|
-
:
|
|
839
|
+
v-if="settings"
|
|
840
|
+
:model-value="settings.mcpConfirmation"
|
|
841
|
+
:disabled="enforced"
|
|
737
842
|
label="Ask before destructive AI actions over MCP"
|
|
738
843
|
@update:model-value="toggle"
|
|
739
844
|
/>
|
|
740
845
|
</template>
|
|
741
846
|
```
|
|
742
847
|
|
|
848
|
+
- Mount the switch only once `settings` is loaded (`v-if`). reka-ui's `Switch` decides at mount
|
|
849
|
+
whether it is controlled: mounted with `undefined`, for example while `onMounted(load)` is still
|
|
850
|
+
running, it keeps its own state, and a refused change then stays flipped although `settings`
|
|
851
|
+
kept the old value.
|
|
743
852
|
- `settings` is always the value as it takes effect, never just the stored choice. `update()`
|
|
744
853
|
adopts the server's answer instead of the input.
|
|
745
854
|
- `enforced` is true when the server fixed the value (`ai.mcp.confirmation.enforced`). Show the
|
|
@@ -756,6 +865,7 @@ async function toggle(value: boolean) {
|
|
|
756
865
|
| `useLtAiChat()` | Multi-turn chat state, streaming, budget, confirmation, `stop()`/`clear()`, optional `maxMessages` cap, auto-stop on component unmount |
|
|
757
866
|
| `useLtAiConnections()` | User self-service: available connections + `select()` (`selected`/`locked`) |
|
|
758
867
|
| `useLtAiSettings()` | The user's own AI settings: the MCP confirmation switch + `update()` (`enforced`) |
|
|
868
|
+
| `useLtAiStatus()` | Whether the API's AI module is on (`enabled`, `mcp`), shared state, one request per app start |
|
|
759
869
|
| `useLtAiUsage()` | Full token/prompt usage breakdown (`GET /ai/usage`) |
|
|
760
870
|
| `useLtAiPrompts()` | User-facing CRUD for re-usable prompt snippets ("Vorlagen", `scope: 'user'` / `'tenant'`) |
|
|
761
871
|
| `useLtAiPlaceholders()` | Loads `{{placeholder}}` registry from the backend so slot / prompt editors render a dynamic helper sidebar |
|
|
@@ -870,6 +980,7 @@ The package works **with or without** `@nuxtjs/i18n`:
|
|
|
870
980
|
| `useLtAiChat()` | Multi-turn chat state, streaming, budget summary, confirmation gate, `maxMessages` cap |
|
|
871
981
|
| `useLtAiConnections()` | User self-service connection list + `select()` |
|
|
872
982
|
| `useLtAiSettings()` | The user's own AI settings (MCP confirmation switch, nest-server >= 12.3.0) |
|
|
983
|
+
| `useLtAiStatus()` | Whether the API's AI module is on and serves MCP (public `GET /ai/status`) |
|
|
873
984
|
| `useLtAiUsage()` | Token/prompt usage breakdown per user / tenant |
|
|
874
985
|
| `useLtAiPrompts()` | User-facing prompt snippet CRUD (`'user'` / `'tenant'` scope) |
|
|
875
986
|
| `useLtAiPlaceholders()` | Loads the backend's `{{placeholder}}` registry |
|
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.
|
|
6
|
+
declare const version = "1.24.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
package/dist/module.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { defineNuxtModule, createResolver, tryResolveModule, addImports, addComponent, addPlugin, addRouteMiddleware } from '@nuxt/kit';
|
|
1
|
+
import { defineNuxtModule, createResolver, tryResolveModule, addImports, addComponent, addPlugin, addRouteMiddleware, addServerHandler } from '@nuxt/kit';
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
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';
|
|
3
4
|
|
|
4
5
|
const name = "@lenne.tech/nuxt-extensions";
|
|
5
|
-
const version = "1.
|
|
6
|
+
const version = "1.24.0";
|
|
6
7
|
const configKey = "ltExtensions";
|
|
7
8
|
const DEFAULT_LT_AUTH_STATE_COOKIE = "lt-auth-state";
|
|
8
9
|
const DEFAULT_LT_JWT_TOKEN_COOKIE = "lt-jwt-token";
|
|
@@ -46,6 +47,12 @@ const defaultOptions = {
|
|
|
46
47
|
preHydrationInput: {
|
|
47
48
|
enabled: true
|
|
48
49
|
},
|
|
50
|
+
// Only the switch. `defineNuxtModule` merges these defaults with `defu`, which concatenates arrays,
|
|
51
|
+
// so a list here would be appended to the project's own. The other defaults are set where the
|
|
52
|
+
// option is read, in the `securityTxt` block of `setup`.
|
|
53
|
+
securityTxt: {
|
|
54
|
+
enabled: false
|
|
55
|
+
},
|
|
49
56
|
// Opt-in: an app without offline sync gets no auto-import and no sign-out cleanup
|
|
50
57
|
sync: {
|
|
51
58
|
basePath: "/sync",
|
|
@@ -79,6 +86,7 @@ const module$1 = defineNuxtModule({
|
|
|
79
86
|
formLabelAssociation: { ...defaultOptions.formLabelAssociation, ...options.formLabelAssociation },
|
|
80
87
|
i18n: { ...defaultOptions.i18n, ...options.i18n },
|
|
81
88
|
preHydrationInput: { ...defaultOptions.preHydrationInput, ...options.preHydrationInput },
|
|
89
|
+
securityTxt: { ...defaultOptions.securityTxt, ...options.securityTxt },
|
|
82
90
|
sync: { ...defaultOptions.sync, ...options.sync },
|
|
83
91
|
tus: { ...defaultOptions.tus, ...options.tus }
|
|
84
92
|
};
|
|
@@ -205,6 +213,7 @@ const module$1 = defineNuxtModule({
|
|
|
205
213
|
{ name: "useLtAiChat", from: resolve("./runtime/composables/use-lt-ai-chat") },
|
|
206
214
|
{ name: "useLtAiConnections", from: resolve("./runtime/composables/use-lt-ai-connections") },
|
|
207
215
|
{ name: "useLtAiSettings", from: resolve("./runtime/composables/use-lt-ai-settings") },
|
|
216
|
+
{ name: "useLtAiStatus", from: resolve("./runtime/composables/use-lt-ai-status") },
|
|
208
217
|
{ name: "useLtAiUsage", from: resolve("./runtime/composables/use-lt-ai-usage") },
|
|
209
218
|
{ name: "useLtAiPrompts", from: resolve("./runtime/composables/use-lt-ai-prompts") },
|
|
210
219
|
{ name: "useLtAiPlaceholders", from: resolve("./runtime/composables/use-lt-ai-placeholders") },
|
|
@@ -255,6 +264,33 @@ const module$1 = defineNuxtModule({
|
|
|
255
264
|
if (resolvedOptions.formLabelAssociation.enabled !== false) {
|
|
256
265
|
addPlugin(resolve("./runtime/plugins/form-label-association.client"));
|
|
257
266
|
}
|
|
267
|
+
if (resolvedOptions.securityTxt.enabled === true) {
|
|
268
|
+
const securityTxt = resolvedOptions.securityTxt;
|
|
269
|
+
rc.public.siteUrl ??= "";
|
|
270
|
+
nuxt.options.runtimeConfig.ltExtensions = {
|
|
271
|
+
...nuxt.options.runtimeConfig.ltExtensions,
|
|
272
|
+
securityTxt: {
|
|
273
|
+
contact: securityTxt.contact ?? SECURITY_TXT_DEFAULT_CONTACT,
|
|
274
|
+
hosts: securityTxt.hosts ?? [],
|
|
275
|
+
policy: securityTxt.policy ?? "",
|
|
276
|
+
preferredLanguages: securityTxt.preferredLanguages ?? SECURITY_TXT_DEFAULT_LANGUAGES,
|
|
277
|
+
validDays: securityTxt.validDays ?? SECURITY_TXT_DEFAULT_VALID_DAYS
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
addServerHandler({ handler: resolve("./runtime/server/routes/security-txt"), route: SECURITY_TXT_PATH });
|
|
281
|
+
nuxt.options.routeRules = {
|
|
282
|
+
...nuxt.options.routeRules,
|
|
283
|
+
[SECURITY_TXT_PATH]: { ...nuxt.options.routeRules?.[SECURITY_TXT_PATH], prerender: false }
|
|
284
|
+
};
|
|
285
|
+
nuxt.options.nitro ??= {};
|
|
286
|
+
nuxt.options.nitro.prerender ??= {};
|
|
287
|
+
nuxt.options.nitro.prerender.ignore = [...nuxt.options.nitro.prerender.ignore ?? [], SECURITY_TXT_PATH];
|
|
288
|
+
if (nuxt.options._generate || nuxt.options.nitro.static) {
|
|
289
|
+
console.warn(
|
|
290
|
+
`[${name}] securityTxt is enabled, but this is a static build (nuxt generate): there is no server to answer ${SECURITY_TXT_PATH}, so the file will be missing. Serve it with a server build, or place a static file yourself and keep its Expires current.`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
258
294
|
nuxt.options.build.transpile.push(resolve("./runtime"));
|
|
259
295
|
nuxt.options.build.transpile.push("tus-js-client");
|
|
260
296
|
if (await tryResolveModule("tus-js-client", [pathToFileURL(`${nuxt.options.rootDir}/`)])) {
|
|
@@ -9,6 +9,7 @@ export { useLtAi } from './use-lt-ai.js';
|
|
|
9
9
|
export { useLtAiChat } from './use-lt-ai-chat.js';
|
|
10
10
|
export { useLtAiConnections } from './use-lt-ai-connections.js';
|
|
11
11
|
export { useLtAiSettings } from './use-lt-ai-settings.js';
|
|
12
|
+
export { useLtAiStatus } from './use-lt-ai-status.js';
|
|
12
13
|
export { useLtAiUsage } from './use-lt-ai-usage.js';
|
|
13
14
|
export { useLtAiAdmin } from './use-lt-ai-admin.js';
|
|
14
15
|
export { useLtAiPrompts } from './use-lt-ai-prompts.js';
|
|
@@ -9,6 +9,7 @@ export { useLtAi } from "./use-lt-ai.js";
|
|
|
9
9
|
export { useLtAiChat } from "./use-lt-ai-chat.js";
|
|
10
10
|
export { useLtAiConnections } from "./use-lt-ai-connections.js";
|
|
11
11
|
export { useLtAiSettings } from "./use-lt-ai-settings.js";
|
|
12
|
+
export { useLtAiStatus } from "./use-lt-ai-status.js";
|
|
12
13
|
export { useLtAiUsage } from "./use-lt-ai-usage.js";
|
|
13
14
|
export { useLtAiAdmin } from "./use-lt-ai-admin.js";
|
|
14
15
|
export { useLtAiPrompts } from "./use-lt-ai-prompts.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the API's AI module is on, and whether it serves MCP. Backed by the
|
|
3
|
+
* public `GET /ai/status` of nest-server: 200 `{ mcp }` while the module is on,
|
|
4
|
+
* 404 while it is off (the route does not exist then).
|
|
5
|
+
*
|
|
6
|
+
* The state is shared app-wide (`useState`) and travels from SSR to the client,
|
|
7
|
+
* so layout, route middleware and pages together cause one request per app
|
|
8
|
+
* start. It is sent without credentials: the endpoint is public, and the
|
|
9
|
+
* auth-aware fetch would start its 401 handling for nothing.
|
|
10
|
+
*
|
|
11
|
+
* Fails closed: anything but a well-formed 200 leaves `enabled` false. A 404 is
|
|
12
|
+
* an answer ("AI is off") and sets no `error`; a network error, a timeout, any
|
|
13
|
+
* other status or a malformed body sets `error`.
|
|
14
|
+
*/
|
|
15
|
+
import type { UseLtAiStatusReturn } from '../types/ai.js';
|
|
16
|
+
export declare function useLtAiStatus(): UseLtAiStatusReturn;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { computed, useNuxtApp, useState } from "#imports";
|
|
2
|
+
import { buildLtAiUrl } from "../lib/ai.js";
|
|
3
|
+
const LT_AI_STATUS_KEY = "lt-ai-status";
|
|
4
|
+
const LT_AI_STATUS_TIMEOUT_MS = 5e3;
|
|
5
|
+
const inflight = /* @__PURE__ */ new WeakMap();
|
|
6
|
+
async function fetchLtAiStatus() {
|
|
7
|
+
const failed = (error) => ({ enabled: false, error, loaded: true, mcp: false });
|
|
8
|
+
let response;
|
|
9
|
+
try {
|
|
10
|
+
response = await fetch(buildLtAiUrl("/status"), {
|
|
11
|
+
credentials: "omit",
|
|
12
|
+
headers: { Accept: "application/json" },
|
|
13
|
+
signal: AbortSignal.timeout(LT_AI_STATUS_TIMEOUT_MS)
|
|
14
|
+
});
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return failed(err?.name === "TimeoutError" ? "AI status request timed out" : "AI status request failed");
|
|
17
|
+
}
|
|
18
|
+
if (response.status === 404) {
|
|
19
|
+
return { enabled: false, error: null, loaded: true, mcp: false };
|
|
20
|
+
}
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
return failed(`HTTP ${response.status}`);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const body = await response.json();
|
|
26
|
+
if (typeof body?.mcp === "boolean") {
|
|
27
|
+
return { enabled: true, error: null, loaded: true, mcp: body.mcp };
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
}
|
|
31
|
+
return failed("Unexpected AI status response");
|
|
32
|
+
}
|
|
33
|
+
export function useLtAiStatus() {
|
|
34
|
+
const state = useState(LT_AI_STATUS_KEY, () => ({ enabled: false, error: null, loaded: false, mcp: false }));
|
|
35
|
+
const nuxtApp = useNuxtApp();
|
|
36
|
+
function load(options) {
|
|
37
|
+
if (state.value.loaded && !options?.force) {
|
|
38
|
+
return Promise.resolve();
|
|
39
|
+
}
|
|
40
|
+
const running = inflight.get(nuxtApp);
|
|
41
|
+
if (running) {
|
|
42
|
+
return running;
|
|
43
|
+
}
|
|
44
|
+
const request = fetchLtAiStatus().then((status) => {
|
|
45
|
+
state.value = status;
|
|
46
|
+
}).finally(() => inflight.delete(nuxtApp));
|
|
47
|
+
inflight.set(nuxtApp, request);
|
|
48
|
+
return request;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
enabled: computed(() => state.value.enabled),
|
|
52
|
+
error: computed(() => state.value.error),
|
|
53
|
+
load,
|
|
54
|
+
loaded: computed(() => state.value.loaded),
|
|
55
|
+
mcp: computed(() => state.value.mcp)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -323,7 +323,31 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
|
|
|
323
323
|
headers?: {} | {
|
|
324
324
|
[x: string]: string | undefined;
|
|
325
325
|
accept?: ((string & {}) | "application/json" | "text/plain" | "application/octet-stream") | undefined;
|
|
326
|
-
"content-type"
|
|
326
|
+
"content-type"
|
|
327
|
+
/**
|
|
328
|
+
* Register additional Better Auth plugins before auth client initialization.
|
|
329
|
+
*
|
|
330
|
+
* Call this in a Nuxt plugin (client-side) or in app.vue setup before
|
|
331
|
+
* the auth client is used.
|
|
332
|
+
*
|
|
333
|
+
* If the auth client was already created, it will be automatically recreated
|
|
334
|
+
* with the new plugins on next access.
|
|
335
|
+
*
|
|
336
|
+
* @example
|
|
337
|
+
* ```typescript
|
|
338
|
+
* // plugins/auth-plugins.client.ts
|
|
339
|
+
* import { registerLtAuthPlugins } from '@lenne.tech/nuxt-extensions/lib';
|
|
340
|
+
* import { organizationClient, magicLinkClient } from 'better-auth/client/plugins';
|
|
341
|
+
*
|
|
342
|
+
* export default defineNuxtPlugin(() => {
|
|
343
|
+
* registerLtAuthPlugins([
|
|
344
|
+
* organizationClient(),
|
|
345
|
+
* magicLinkClient(),
|
|
346
|
+
* ]);
|
|
347
|
+
* });
|
|
348
|
+
* ```
|
|
349
|
+
*/
|
|
350
|
+
?: ((string & {}) | "application/x-www-form-urlencoded" | "application/json" | "text/plain" | "application/octet-stream" | "multipart/form-data") | undefined;
|
|
327
351
|
authorization?: ((string & {}) | `Bearer ${string}` | `Basic ${string}`) | undefined;
|
|
328
352
|
} | undefined;
|
|
329
353
|
body?: any;
|
|
@@ -18,6 +18,10 @@ export const PASSKEY_ERROR_CODES = Object.freeze({
|
|
|
18
18
|
CHALLENGE_NOT_FOUND: { code: "CHALLENGE_NOT_FOUND", message: "Challenge not found" },
|
|
19
19
|
FAILED_TO_UPDATE_PASSKEY: { code: "FAILED_TO_UPDATE_PASSKEY", message: "Failed to update passkey" },
|
|
20
20
|
FAILED_TO_VERIFY_REGISTRATION: { code: "FAILED_TO_VERIFY_REGISTRATION", message: "Failed to verify registration" },
|
|
21
|
+
ORIGIN_NOT_CONFIGURED: {
|
|
22
|
+
code: "ORIGIN_NOT_CONFIGURED",
|
|
23
|
+
message: "Passkey origin is not configured. Set the `origin` option of the passkey plugin or configure `baseURL`."
|
|
24
|
+
},
|
|
21
25
|
PASSKEY_NOT_FOUND: { code: "PASSKEY_NOT_FOUND", message: "Passkey not found" },
|
|
22
26
|
PREVIOUSLY_REGISTERED: { code: "PREVIOUSLY_REGISTERED", message: "Previously registered" },
|
|
23
27
|
REGISTRATION_CANCELLED: { code: "REGISTRATION_CANCELLED", message: "Registration cancelled" },
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security.txt according to RFC 9116, built per request.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the lenne.Tech website (DEV-3602), where it has been live since 2026-10-13.
|
|
5
|
+
* Pure functions only, so the route stays a thin adapter and everything that decides the
|
|
6
|
+
* file's content is unit-testable without a Nitro context.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SECURITY_TXT_PATH = "/.well-known/security.txt";
|
|
9
|
+
export declare const SECURITY_TXT_DEFAULT_CONTACT = "mailto:security@lenne.tech";
|
|
10
|
+
export declare const SECURITY_TXT_DEFAULT_LANGUAGES: string[];
|
|
11
|
+
export declare const SECURITY_TXT_DEFAULT_VALID_DAYS = 180;
|
|
12
|
+
export interface SecurityTxtOptions {
|
|
13
|
+
/** Contact URIs (`mailto:`, `https:` or `tel:`), in order of preference. */
|
|
14
|
+
contacts: string[];
|
|
15
|
+
/** Point in time the file is built for; `Expires` is derived from it. */
|
|
16
|
+
now: Date;
|
|
17
|
+
/** Origins the file is served from; each becomes one `Canonical` line. */
|
|
18
|
+
origins: string[];
|
|
19
|
+
/** URI of the vulnerability disclosure policy, or a path resolved against the first origin. */
|
|
20
|
+
policy?: string;
|
|
21
|
+
/** Language tags in order of preference. */
|
|
22
|
+
preferredLanguages?: string[];
|
|
23
|
+
/** Days between `now` and `Expires`. */
|
|
24
|
+
validDays?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Normalises the configured contact(s). Accepts a string or an array, because a runtime override
|
|
28
|
+
* via `NUXT_LT_EXTENSIONS_SECURITY_TXT_CONTACT` arrives as a plain string.
|
|
29
|
+
*
|
|
30
|
+
* Whatever is not a usable URI is dropped. `security@example.com` is the obvious typo, and scanners
|
|
31
|
+
* treat the file it would produce as invalid; with no contact left, the route answers 404 instead.
|
|
32
|
+
*/
|
|
33
|
+
export declare function normalizeSecurityTxtContacts(contact: unknown): string[];
|
|
34
|
+
/**
|
|
35
|
+
* Normalises a list option (languages, hosts). A runtime override via an environment variable
|
|
36
|
+
* arrives as a string, so `"de, en"` is accepted as well as `['de', 'en']`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function normalizeSecurityTxtList(value: unknown): string[];
|
|
39
|
+
/**
|
|
40
|
+
* Clamps `validDays` into the window RFC 9116 recommends. A non-finite or non-positive value
|
|
41
|
+
* falls back to the default instead of producing an `Expires` in the past.
|
|
42
|
+
*/
|
|
43
|
+
export declare function normalizeSecurityTxtValidDays(validDays: unknown): number;
|
|
44
|
+
/**
|
|
45
|
+
* Turns a configured site URL into an origin (`https://example.com`), dropping path, query and
|
|
46
|
+
* trailing slash. Anything that is not an absolute http(s) URL yields `undefined`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function toSecurityTxtOrigin(url: string | undefined): string | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Turns the requested host into an origin, but only if it is one the site is known to be served
|
|
51
|
+
* from. The host comes from a request header, so anything else must not end up in the file.
|
|
52
|
+
*/
|
|
53
|
+
export declare function allowedRequestOrigin(host: string | undefined, allowedHosts: string[]): string | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Builds a security.txt according to RFC 9116.
|
|
56
|
+
*
|
|
57
|
+
* `Expires` is relative to `now`, so a file built per request never goes stale, no matter how
|
|
58
|
+
* long ago the last deployment was. It is cut to midnight UTC, which makes the file valid for up
|
|
59
|
+
* to a day less than `validDays` and keeps it byte-identical for a whole day.
|
|
60
|
+
*/
|
|
61
|
+
export declare function buildSecurityTxt(options: SecurityTxtOptions): string;
|
|
62
|
+
/** The server-only runtime config the module writes to `runtimeConfig.ltExtensions.securityTxt`. */
|
|
63
|
+
export interface SecurityTxtRuntimeConfig {
|
|
64
|
+
contact?: string | string[];
|
|
65
|
+
hosts?: string | string[];
|
|
66
|
+
policy?: string;
|
|
67
|
+
preferredLanguages?: string | string[];
|
|
68
|
+
validDays?: number | string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Turns the runtime config and the request into the file's content, or `undefined` when no
|
|
72
|
+
* usable contact is left (the route then answers 404).
|
|
73
|
+
*
|
|
74
|
+
* `Canonical` names the configured site URL and, in addition, the requested host if it is one of
|
|
75
|
+
* the configured `hosts`. A site served under several hostnames without a redirect needs the
|
|
76
|
+
* second line: RFC 9116 expects the URI a file was fetched from among its Canonical lines.
|
|
77
|
+
*/
|
|
78
|
+
export declare function resolveSecurityTxt(input: {
|
|
79
|
+
config: SecurityTxtRuntimeConfig | undefined;
|
|
80
|
+
now: Date;
|
|
81
|
+
requestHost: string | undefined;
|
|
82
|
+
siteUrl: string | undefined;
|
|
83
|
+
}): string | undefined;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const SECURITY_TXT_PATH = "/.well-known/security.txt";
|
|
2
|
+
export const SECURITY_TXT_DEFAULT_CONTACT = "mailto:security@lenne.tech";
|
|
3
|
+
export const SECURITY_TXT_DEFAULT_LANGUAGES = ["de", "en"];
|
|
4
|
+
export const SECURITY_TXT_DEFAULT_VALID_DAYS = 180;
|
|
5
|
+
const SECURITY_TXT_MAX_VALID_DAYS = 365;
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
7
|
+
const CONTACT_URI = /^(?:mailto:|tel:|https:\/\/)\S/i;
|
|
8
|
+
function isSingleToken(value) {
|
|
9
|
+
return typeof value === "string" && value.length > 0 && !/\s/.test(value);
|
|
10
|
+
}
|
|
11
|
+
export function normalizeSecurityTxtContacts(contact) {
|
|
12
|
+
const values = Array.isArray(contact) ? contact : [contact];
|
|
13
|
+
return values.map((value) => typeof value === "string" ? value.trim() : value).filter(isSingleToken).filter((value) => CONTACT_URI.test(value));
|
|
14
|
+
}
|
|
15
|
+
export function normalizeSecurityTxtList(value) {
|
|
16
|
+
const values = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : [];
|
|
17
|
+
return values.map((entry) => typeof entry === "string" ? entry.trim() : entry).filter(isSingleToken);
|
|
18
|
+
}
|
|
19
|
+
export function normalizeSecurityTxtValidDays(validDays) {
|
|
20
|
+
const days = Number(validDays);
|
|
21
|
+
if (!Number.isFinite(days) || days < 1) {
|
|
22
|
+
return SECURITY_TXT_DEFAULT_VALID_DAYS;
|
|
23
|
+
}
|
|
24
|
+
return Math.min(Math.floor(days), SECURITY_TXT_MAX_VALID_DAYS);
|
|
25
|
+
}
|
|
26
|
+
export function toSecurityTxtOrigin(url) {
|
|
27
|
+
if (!url) {
|
|
28
|
+
return void 0;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const parsed = new URL(url);
|
|
32
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.origin : void 0;
|
|
33
|
+
} catch {
|
|
34
|
+
return void 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function allowedRequestOrigin(host, allowedHosts) {
|
|
38
|
+
const name = host?.trim().toLowerCase();
|
|
39
|
+
const allowed = allowedHosts.map((entry) => entry.trim().toLowerCase());
|
|
40
|
+
return name && allowed.includes(name) ? `https://${name}` : void 0;
|
|
41
|
+
}
|
|
42
|
+
function resolveAgainstOrigin(value, origin) {
|
|
43
|
+
if (!isSingleToken(value)) {
|
|
44
|
+
return void 0;
|
|
45
|
+
}
|
|
46
|
+
if (!value.startsWith("/")) {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
return origin ? `${origin}${value}` : void 0;
|
|
50
|
+
}
|
|
51
|
+
export function buildSecurityTxt(options) {
|
|
52
|
+
const { contacts, now, origins, policy, preferredLanguages = [] } = options;
|
|
53
|
+
const validDays = normalizeSecurityTxtValidDays(options.validDays ?? SECURITY_TXT_DEFAULT_VALID_DAYS);
|
|
54
|
+
const expires = new Date(Math.floor((now.getTime() + validDays * DAY_MS) / DAY_MS) * DAY_MS);
|
|
55
|
+
const canonicals = [...new Set(origins.map((origin) => origin.replace(/\/+$/, "")).filter(Boolean))];
|
|
56
|
+
const languages = preferredLanguages.map((language) => language.trim()).filter(isSingleToken);
|
|
57
|
+
const lines = [...contacts.filter(isSingleToken).map((contact) => `Contact: ${contact}`), `Expires: ${expires.toISOString()}`];
|
|
58
|
+
const policyUri = resolveAgainstOrigin(policy, canonicals[0]);
|
|
59
|
+
if (policyUri) {
|
|
60
|
+
lines.push(`Policy: ${policyUri}`);
|
|
61
|
+
}
|
|
62
|
+
if (languages.length) {
|
|
63
|
+
lines.push(`Preferred-Languages: ${languages.join(", ")}`);
|
|
64
|
+
}
|
|
65
|
+
for (const origin of canonicals) {
|
|
66
|
+
lines.push(`Canonical: ${origin}${SECURITY_TXT_PATH}`);
|
|
67
|
+
}
|
|
68
|
+
return `${lines.join("\n")}
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
export function resolveSecurityTxt(input) {
|
|
72
|
+
const config = input.config ?? {};
|
|
73
|
+
const contacts = normalizeSecurityTxtContacts(config.contact);
|
|
74
|
+
if (!contacts.length) {
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
const siteOrigin = toSecurityTxtOrigin(input.siteUrl);
|
|
78
|
+
const requestOrigin = allowedRequestOrigin(input.requestHost, normalizeSecurityTxtList(config.hosts));
|
|
79
|
+
return buildSecurityTxt({
|
|
80
|
+
contacts,
|
|
81
|
+
now: input.now,
|
|
82
|
+
origins: [siteOrigin, requestOrigin].filter((origin) => !!origin),
|
|
83
|
+
policy: config.policy,
|
|
84
|
+
preferredLanguages: normalizeSecurityTxtList(config.preferredLanguages),
|
|
85
|
+
validDays: normalizeSecurityTxtValidDays(config.validDays)
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serves `/.well-known/security.txt` (RFC 9116). Registered by the module only when
|
|
3
|
+
* `ltExtensions.securityTxt.enabled` is set.
|
|
4
|
+
*
|
|
5
|
+
* Registered without a method on purpose: scanners probe with HEAD as well as GET, and a
|
|
6
|
+
* GET-only handler would answer HEAD with the 404 catch-all.
|
|
7
|
+
*/
|
|
8
|
+
declare const _default: any;
|
|
9
|
+
export default _default;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createError, defineEventHandler, getRequestHost, setResponseHeader, useRuntimeConfig } from "#imports";
|
|
2
|
+
import { resolveSecurityTxt } from "../lib/security-txt.js";
|
|
3
|
+
export default defineEventHandler((event) => {
|
|
4
|
+
const config = useRuntimeConfig(event);
|
|
5
|
+
const siteUrl = config.public?.siteUrl;
|
|
6
|
+
const ltExtensions = config.ltExtensions;
|
|
7
|
+
const body = resolveSecurityTxt({
|
|
8
|
+
config: ltExtensions?.securityTxt,
|
|
9
|
+
now: /* @__PURE__ */ new Date(),
|
|
10
|
+
requestHost: getRequestHost(event, { xForwardedHost: true }),
|
|
11
|
+
siteUrl: typeof siteUrl === "string" ? siteUrl : void 0
|
|
12
|
+
});
|
|
13
|
+
if (!body) {
|
|
14
|
+
throw createError({ statusCode: 404, statusMessage: "Not Found" });
|
|
15
|
+
}
|
|
16
|
+
setResponseHeader(event, "content-type", "text/plain; charset=utf-8");
|
|
17
|
+
setResponseHeader(event, "cache-control", "no-store");
|
|
18
|
+
return body;
|
|
19
|
+
});
|
|
@@ -137,6 +137,17 @@ export interface LtAiSettings {
|
|
|
137
137
|
export interface LtAiSettingsInput {
|
|
138
138
|
mcpConfirmation?: boolean;
|
|
139
139
|
}
|
|
140
|
+
/** Whether the API's AI module is on (`GET /ai/status`), as `useLtAiStatus()` holds it. */
|
|
141
|
+
export interface LtAiStatus {
|
|
142
|
+
/** True only after a well-formed 200: the AI module is on. */
|
|
143
|
+
enabled: boolean;
|
|
144
|
+
/** Why the status could not be read (network, timeout, other status, malformed body); null for an answer, 404 included. */
|
|
145
|
+
error: null | string;
|
|
146
|
+
/** True once an answer or an error is in. */
|
|
147
|
+
loaded: boolean;
|
|
148
|
+
/** Whether the API serves MCP; false whenever `enabled` is false. */
|
|
149
|
+
mcp: boolean;
|
|
150
|
+
}
|
|
140
151
|
/** Usage for a single scope (user or tenant). */
|
|
141
152
|
export interface LtAiUsageScope {
|
|
142
153
|
maxPrompts?: number;
|
|
@@ -440,6 +451,16 @@ export interface UseLtAiConnectionsReturn {
|
|
|
440
451
|
select: (connectionId: string) => Promise<void>;
|
|
441
452
|
selected: ComputedRef<LtAiAvailableConnection | undefined>;
|
|
442
453
|
}
|
|
454
|
+
export interface UseLtAiStatusReturn {
|
|
455
|
+
enabled: ComputedRef<boolean>;
|
|
456
|
+
error: ComputedRef<null | string>;
|
|
457
|
+
/** Reads the status once per app start; `force` asks again. Never throws. */
|
|
458
|
+
load: (options?: {
|
|
459
|
+
force?: boolean;
|
|
460
|
+
}) => Promise<void>;
|
|
461
|
+
loaded: ComputedRef<boolean>;
|
|
462
|
+
mcp: ComputedRef<boolean>;
|
|
463
|
+
}
|
|
443
464
|
export interface UseLtAiSettingsReturn {
|
|
444
465
|
/** True when the server fixed the MCP confirmation: show the switch disabled. */
|
|
445
466
|
enforced: ComputedRef<boolean>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { LtAuthClientConfig, LtAuthMode, LtAuthResponse, LtAuthState, LtPasskeyAuthResult, LtPasskeyRegisterResult, LtUser, LtUserInput, UseLtAuthReturn } from './auth.js';
|
|
2
2
|
export type { LtFileInfo, LtUploadItem, LtUploadOptions, LtUploadProgress, LtUploadStatus, UseLtFileReturn, UseLtTusUploadReturn } from './upload.js';
|
|
3
|
-
export type { LtAiModuleOptions, LtAuthCookieNamesOptions, LtAuthModuleOptions, LtErrorTranslationModuleOptions, LtExtensionsModuleOptions, LtExtensionsPublicRuntimeConfig, LtFormLabelAssociationOptions, LtI18nModuleOptions, LtPreHydrationInputOptions, LtSystemSetupModuleOptions, LtTusModuleOptions, } from './module.js';
|
|
3
|
+
export type { LtAiModuleOptions, LtAuthCookieNamesOptions, LtAuthModuleOptions, LtErrorTranslationModuleOptions, LtExtensionsModuleOptions, LtExtensionsPublicRuntimeConfig, LtFormLabelAssociationOptions, LtI18nModuleOptions, LtPreHydrationInputOptions, LtSecurityTxtOptions, LtSystemSetupModuleOptions, LtTusModuleOptions, } from './module.js';
|
|
4
4
|
export type { LtErrorTranslationResponse, LtParsedError, UseLtErrorTranslationReturn } from './error.js';
|
|
5
5
|
export type { LtSyncConflict, LtSyncConflictResolution, LtSyncIdFormat, LtSyncItemStatus, LtSyncModuleOptions, LtSyncPushResult, LtSyncRecord, LtSyncResult, UseLtOfflineSyncOptions, UseLtOfflineSyncReturn, } from './sync.js';
|
|
6
|
-
export type { LtAiAction, LtAiAvailableConnection, LtAiBudgetLimit, LtAiBudgetSummary, LtAiConnection, LtAiConnectionInput, LtAiConnectionPreference, LtAiEffectiveSlot, LtAiInteraction, LtAiMessage, LtAiMode, LtAiPlaceholder, LtAiPrompt, LtAiPromptHint, LtAiPromptHintInput, LtAiPromptInput, LtAiPromptRunInput, LtAiResponse, LtAiSettings, LtAiSettingsInput, LtAiSlot, LtAiSlotInput, LtAiStreamEvent, LtAiStreamHandlers, LtAiUsage, LtAiUsageInfo, LtAiUsageScope, UseLtAiAdminReturn, UseLtAiChatOptions, UseLtAiChatReturn, UseLtAiConnectionsReturn, UseLtAiPlaceholdersReturn, UseLtAiPromptsReturn, UseLtAiReturn, UseLtAiSettingsReturn, UseLtAiUsageReturn, } from './ai.js';
|
|
6
|
+
export type { LtAiAction, LtAiAvailableConnection, LtAiBudgetLimit, LtAiBudgetSummary, LtAiConnection, LtAiConnectionInput, LtAiConnectionPreference, LtAiEffectiveSlot, LtAiInteraction, LtAiMessage, LtAiMode, LtAiPlaceholder, LtAiPrompt, LtAiPromptHint, LtAiPromptHintInput, LtAiPromptInput, LtAiPromptRunInput, LtAiResponse, LtAiSettings, LtAiSettingsInput, LtAiSlot, LtAiStatus, LtAiSlotInput, LtAiStreamEvent, LtAiStreamHandlers, LtAiUsage, LtAiUsageInfo, LtAiUsageScope, UseLtAiAdminReturn, UseLtAiChatOptions, UseLtAiChatReturn, UseLtAiConnectionsReturn, UseLtAiPlaceholdersReturn, UseLtAiPromptsReturn, UseLtAiReturn, UseLtAiSettingsReturn, UseLtAiStatusReturn, UseLtAiUsageReturn, } from './ai.js';
|
|
@@ -148,6 +148,8 @@ export interface LtExtensionsModuleOptions {
|
|
|
148
148
|
i18n?: LtI18nModuleOptions;
|
|
149
149
|
/** Pre-hydration input preservation configuration */
|
|
150
150
|
preHydrationInput?: LtPreHydrationInputOptions;
|
|
151
|
+
/** security.txt (RFC 9116) at `/.well-known/security.txt` */
|
|
152
|
+
securityTxt?: LtSecurityTxtOptions;
|
|
151
153
|
/** Offline sync with the nest-server sync module (opt-in) */
|
|
152
154
|
sync?: LtSyncModuleOptions;
|
|
153
155
|
/** TUS upload module configuration */
|
|
@@ -271,6 +273,67 @@ export interface LtPreHydrationInputOptions {
|
|
|
271
273
|
*/
|
|
272
274
|
maxRestoreMs?: number;
|
|
273
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* security.txt according to RFC 9116, served at `/.well-known/security.txt`.
|
|
278
|
+
*
|
|
279
|
+
* The file tells security researchers where to report a vulnerability. Off by default: only turn
|
|
280
|
+
* it on where somebody actually reads the contact address — a published address that nobody
|
|
281
|
+
* answers is worse than none.
|
|
282
|
+
*
|
|
283
|
+
* The file is built per request. `Expires` is always "now + `validDays`", cut to midnight UTC, so
|
|
284
|
+
* it never runs out between deployments. `Canonical` names `runtimeConfig.public.siteUrl`
|
|
285
|
+
* (`NUXT_PUBLIC_SITE_URL`, the module declares the key) and, in addition, the requested host if it
|
|
286
|
+
* is listed in `hosts`.
|
|
287
|
+
*
|
|
288
|
+
* Everything except `enabled` can be overridden per stage at runtime, without a rebuild:
|
|
289
|
+
* `NUXT_LT_EXTENSIONS_SECURITY_TXT_CONTACT`, `…_HOSTS`, `…_POLICY`, `…_PREFERRED_LANGUAGES`,
|
|
290
|
+
* `…_VALID_DAYS`. Blanking the contact that way makes the route answer 404, because a file without
|
|
291
|
+
* `Contact` is invalid.
|
|
292
|
+
*
|
|
293
|
+
* Not available with `nuxt generate`: a static site has no server to compute the file.
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* ```ts
|
|
297
|
+
* export default defineNuxtConfig({
|
|
298
|
+
* ltExtensions: {
|
|
299
|
+
* securityTxt: {
|
|
300
|
+
* enabled: true,
|
|
301
|
+
* contact: 'mailto:security@customer.example',
|
|
302
|
+
* hosts: ['customer.example', 'www.customer.example'],
|
|
303
|
+
* },
|
|
304
|
+
* },
|
|
305
|
+
* });
|
|
306
|
+
* ```
|
|
307
|
+
*/
|
|
308
|
+
export interface LtSecurityTxtOptions {
|
|
309
|
+
/**
|
|
310
|
+
* Contact URI(s), in order of preference: `mailto:`, `https://` or `tel:`. Anything else, such
|
|
311
|
+
* as a bare address, counts as no contact, and the route answers 404.
|
|
312
|
+
* Defaults to `'mailto:security@lenne.tech'`. For a customer project, decide whether reports go
|
|
313
|
+
* to lenne.Tech or to the customer, and set it explicitly.
|
|
314
|
+
*/
|
|
315
|
+
contact?: string | string[];
|
|
316
|
+
/** Serve the file. Defaults to `false`. */
|
|
317
|
+
enabled?: boolean;
|
|
318
|
+
/**
|
|
319
|
+
* Hostnames the site is served from without a redirect (e.g. apex and `www`). A request for one
|
|
320
|
+
* of them adds a second `Canonical` line for that host. The host comes from a request header,
|
|
321
|
+
* so nothing outside this list ever reaches the file. Defaults to `[]`.
|
|
322
|
+
*/
|
|
323
|
+
hosts?: string[];
|
|
324
|
+
/**
|
|
325
|
+
* URI of the vulnerability disclosure policy. A path (`/security-policy`) is resolved against the
|
|
326
|
+
* site URL and left out when no site URL is configured.
|
|
327
|
+
*/
|
|
328
|
+
policy?: string;
|
|
329
|
+
/** Language tags reports may be written in, in order of preference. Defaults to `['de', 'en']`. */
|
|
330
|
+
preferredLanguages?: string[];
|
|
331
|
+
/**
|
|
332
|
+
* Days between the request and `Expires`. Defaults to `180`; clamped to `365` because RFC 9116
|
|
333
|
+
* recommends less than a year, and a value below `1` falls back to the default.
|
|
334
|
+
*/
|
|
335
|
+
validDays?: number;
|
|
336
|
+
}
|
|
274
337
|
/**
|
|
275
338
|
* Public runtime config added by this module
|
|
276
339
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nuxt-extensions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"test": "vitest run",
|
|
70
70
|
"test:watch": "vitest watch",
|
|
71
71
|
"test:coverage": "vitest run --coverage",
|
|
72
|
-
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit",
|
|
72
|
+
"test:types": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.server.json && cd playground && vue-tsc --noEmit",
|
|
73
73
|
"release": "pnpm format:check && pnpm run dev:prepare && pnpm lint && pnpm run version:check && pnpm test:types && pnpm test && pnpm run check:consumer && pnpm prepack",
|
|
74
74
|
"check:consumer": "node scripts/check-consumer-build.mjs",
|
|
75
75
|
"check:manifest": "node scripts/check-package-manifest.mjs",
|
|
@@ -79,9 +79,9 @@
|
|
|
79
79
|
"@nuxt/kit": "^4.0.0"
|
|
80
80
|
},
|
|
81
81
|
"peerDependencies": {
|
|
82
|
-
"@better-auth/passkey": ">=1.7.
|
|
82
|
+
"@better-auth/passkey": ">=1.7.16 <1.8.0",
|
|
83
83
|
"@playwright/test": ">=1.0.0",
|
|
84
|
-
"better-auth": ">=1.7.
|
|
84
|
+
"better-auth": ">=1.7.16 <1.8.0",
|
|
85
85
|
"nuxt": "^4.0.0",
|
|
86
86
|
"tus-js-client": ">=4.0.0"
|
|
87
87
|
},
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
}
|
|
101
101
|
},
|
|
102
102
|
"devDependencies": {
|
|
103
|
-
"@better-auth/passkey": "1.7.
|
|
103
|
+
"@better-auth/passkey": "1.7.16",
|
|
104
104
|
"@nuxt/module-builder": "1.0.3",
|
|
105
105
|
"@nuxt/schema": "4.6.4",
|
|
106
106
|
"@nuxt/ui": "4.12.0",
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
"@types/node": "26.6.6",
|
|
109
109
|
"@vitest/coverage-v8": "5.1.0",
|
|
110
110
|
"@vue/server-renderer": "3.5.44",
|
|
111
|
-
"better-auth": "1.7.
|
|
111
|
+
"better-auth": "1.7.16",
|
|
112
112
|
"cross-spawn": "7.0.6",
|
|
113
113
|
"fake-indexeddb": "6.2.7",
|
|
114
114
|
"happy-dom": "21.0.4",
|