@com55/node-red-ai-access 0.1.0 → 0.2.0-beta.1

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/dist/plugin.d.cts CHANGED
@@ -176,7 +176,7 @@ interface AiAccessPaths {
176
176
  root: string;
177
177
  proposals: string;
178
178
  logs: string;
179
- /** Persisted AI Access settings (`settings.json`). */
179
+ /** Persisted AI Access settings (`config.json`). */
180
180
  settings: string;
181
181
  }
182
182
 
@@ -221,11 +221,16 @@ declare class AuditLog {
221
221
 
222
222
  type ProposalStatus = 'pending' | 'approved' | 'rejected' | 'failed' | 'withdrawn' | 'expired' | 'superseded';
223
223
  type ProposalType = 'replace_flow';
224
- interface ProposalCreator {
224
+ interface AccessLinkProposalCreator {
225
225
  kind: 'access-link';
226
226
  linkId: string;
227
227
  label?: string;
228
228
  }
229
+ type ProposalCreator = AccessLinkProposalCreator | {
230
+ kind: 'oauth';
231
+ clientId: string;
232
+ user: string;
233
+ };
229
234
  type ProposalDecisionBy = {
230
235
  kind: 'admin';
231
236
  username?: string;
@@ -329,11 +334,11 @@ declare class ProposalStore {
329
334
  }
330
335
 
331
336
  /**
332
- * Persisted AI Access settings under `<userDir>/.ai-access/settings.json`.
337
+ * Persisted AI Access settings under `<userDir>/.ai-access/config.json`.
333
338
  *
334
339
  * Precedence for effective values (highest wins):
335
- * 1. Disk file keys present in `.ai-access/settings.json` (sidebar AI Access UI)
336
- * 2. `settings.js` `aiAccess` block
340
+ * 1. `settings.js` `aiAccess` block (locked keys)
341
+ * 2. Disk file keys present in `.ai-access/config.json` (sidebar AI Access UI)
337
342
  * 3. Built-in defaults
338
343
  *
339
344
  * Changes via PUT /mcp/api/v1/settings update memory + disk immediately
@@ -347,6 +352,18 @@ interface PersistedAiAccessSettings {
347
352
  defaultLinkTtlSeconds?: number;
348
353
  defaultDeploymentType?: DeploymentType;
349
354
  maxActiveLinks?: number;
355
+ publicBaseUrl?: string;
356
+ corsAllowedOrigins?: string[];
357
+ rateLimitWindowMs?: number;
358
+ rateLimitMaxPerIp?: number;
359
+ rateLimitMaxPerToken?: number;
360
+ oauth?: {
361
+ enabled?: boolean;
362
+ accessTokenTtlSec?: number;
363
+ refreshIdleTtlSec?: number;
364
+ refreshMaxLifetimeSec?: number;
365
+ dcrMaxClients?: number;
366
+ };
350
367
  }
351
368
  interface EffectiveAiAccessSettings {
352
369
  enabled: boolean;
@@ -354,18 +371,31 @@ interface EffectiveAiAccessSettings {
354
371
  defaultLinkTtlSeconds: number;
355
372
  defaultDeploymentType: DeploymentType;
356
373
  maxActiveLinks: number;
374
+ publicBaseUrl: string;
375
+ corsAllowedOrigins: string[];
376
+ rateLimitWindowMs: number;
377
+ rateLimitMaxPerIp: number;
378
+ rateLimitMaxPerToken: number;
379
+ oauth: {
380
+ enabled: boolean;
381
+ accessTokenTtlSec: number;
382
+ refreshIdleTtlSec: number;
383
+ refreshMaxLifetimeSec: number;
384
+ dcrMaxClients: number;
385
+ };
357
386
  }
358
387
  type SettingsSource = 'default' | 'settings.js' | 'disk';
359
388
  interface EffectiveAiAccessSettingsWithMeta extends EffectiveAiAccessSettings {
360
389
  sources: Record<keyof EffectiveAiAccessSettings, SettingsSource>;
361
390
  }
391
+ type Warn = (message: string) => void;
362
392
  declare class SettingsStore {
363
393
  private readonly paths;
364
394
  private readonly filePath;
365
395
  private disk;
366
396
  private settingsJs;
367
397
  private cached;
368
- constructor(paths: AiAccessPaths, settingsJsAiAccess?: PersistedAiAccessSettings);
398
+ constructor(paths: AiAccessPaths, settingsJsAiAccess?: PersistedAiAccessSettings, warn?: Warn);
369
399
  getEffective(): EffectiveAiAccessSettingsWithMeta;
370
400
  getDisk(): PersistedAiAccessSettings;
371
401
  /**
@@ -470,8 +500,28 @@ interface AiAccessSettings {
470
500
  * Max requests per access-link token per window (default: 300).
471
501
  */
472
502
  rateLimitMaxPerToken?: number;
503
+ /**
504
+ * Explicit public base URL (e.g. `https://nodered.example.com`) used for
505
+ * absolute URLs (access-link `url`, proposal `reviewUrl`, `accessLinksUrl`).
506
+ * Overrides `Host` / `X-Forwarded-*`. Editable in the AI Access settings sidebar unless locked by settings.js.
507
+ */
508
+ publicBaseUrl?: string;
509
+ /**
510
+ * Browser origins allowed to call the AI-facing endpoints (`/mcp/a`,
511
+ * `/mcp/api`, `/mcp/rpc`) cross-origin, e.g. `['https://app.example.com']`.
512
+ * Default: none (no CORS headers). Editable in the AI Access settings sidebar unless locked by settings.js.
513
+ */
514
+ corsAllowedOrigins?: string[];
515
+ oauth?: {
516
+ enabled?: boolean;
517
+ accessTokenTtlSec?: number;
518
+ refreshIdleTtlSec?: number;
519
+ /** Absolute refresh-family lifetime in seconds; default 7776000 (90 days), 0 = unlimited. */
520
+ refreshMaxLifetimeSec?: number;
521
+ dcrMaxClients?: number;
522
+ };
473
523
  }
474
- type AiAccessPermission = 'read' | '*';
524
+ type AiAccessPermission = 'read' | 'propose' | '*';
475
525
  interface AiAccessCapabilities {
476
526
  view: boolean;
477
527
  manageLinks: boolean;
@@ -488,6 +538,8 @@ interface AiAccessStatusCallerBaseUrls {
488
538
  rpc: string;
489
539
  /** OpenAPI YAML URL. */
490
540
  openapi: string;
541
+ /** OpenAPI JSON URL (same spec as `openapi`). */
542
+ openapiJson: string;
491
543
  /** Unauthenticated JSON discovery index. */
492
544
  discovery: string;
493
545
  }
@@ -515,7 +567,13 @@ interface AiAccessStatusCallerAccessLink {
515
567
  */
516
568
  baseUrls?: Partial<AiAccessStatusCallerBaseUrls>;
517
569
  }
518
- type AiAccessStatusCaller = AiAccessStatusCallerAdmin | AiAccessStatusCallerAccessLink;
570
+ interface AiAccessStatusCallerOAuth {
571
+ kind: 'oauth';
572
+ clientId: string;
573
+ user: string;
574
+ scopes: string[];
575
+ }
576
+ type AiAccessStatusCaller = AiAccessStatusCallerAdmin | AiAccessStatusCallerAccessLink | AiAccessStatusCallerOAuth;
519
577
  interface AiAccessStatus {
520
578
  ok: boolean;
521
579
  version: string;
@@ -530,7 +588,7 @@ interface AiAccessStatus {
530
588
  */
531
589
  permission: AiAccessPermission | null;
532
590
  capabilities: AiAccessCapabilities;
533
- storageDir: string;
591
+ storageDir?: string;
534
592
  /** Effective default access-link TTL (seconds). */
535
593
  defaultLinkTtlSeconds: number;
536
594
  /** Effective max concurrent active access links. */
@@ -543,6 +601,8 @@ interface AiAccessStatus {
543
601
  interface NodeRedPluginSettings {
544
602
  userDir: string;
545
603
  httpAdminRoot?: string;
604
+ httpNodeRoot?: string | false;
605
+ httpStatic?: string | string[];
546
606
  adminAuth?: unknown;
547
607
  aiAccess?: AiAccessSettings;
548
608
  }
@@ -552,6 +612,16 @@ interface NodeRedAuthAPI {
552
612
  interface NodeRedPluginAPI {
553
613
  settings: NodeRedPluginSettings;
554
614
  httpAdmin: Express;
615
+ httpNode?: Express;
616
+ nodes?: {
617
+ eachNode(listener: (node: {
618
+ type?: string;
619
+ url?: string;
620
+ }) => void): void;
621
+ };
622
+ events?: {
623
+ on(event: string, listener: () => void): void;
624
+ };
555
625
  auth: NodeRedAuthAPI;
556
626
  plugins: {
557
627
  registerPlugin: (id: string, definition?: Record<string, unknown>) => void;
@@ -570,6 +640,8 @@ interface AccessLinkAuthInfo {
570
640
  createdAt: string;
571
641
  expiresAt: string;
572
642
  createdBy?: string;
643
+ oauthClientId?: string;
644
+ oauthUser?: string;
573
645
  }
574
646
  interface AuthenticatedRequest extends Request {
575
647
  user?: {
package/docs/README.md CHANGED
@@ -18,6 +18,8 @@ bodies. Use a **GET link** only as a fallback for headerless GET-only clients.
18
18
  - [MCP tools reference](./mcp-tools.md) — generated from live `tools/list`
19
19
  schemas
20
20
  - [Error codes](./errors.md) — code, HTTP status, meaning, hint, channels
21
+ - [Releasing](./releasing.md) — version bump, CHANGELOG, tag, npm trusted
22
+ publishing
21
23
  - [OpenAPI 3.1](./api/openapi.yaml) — also served at
22
24
  `GET /mcp/api/v1/openapi.yaml` (JSON: `GET /mcp/api/v1/openapi.json`)
23
25
  - Discovery guide — `GET /mcp/api/v1` (unauthenticated JSON index)
@@ -2,7 +2,7 @@ openapi: 3.1.0
2
2
 
3
3
  info:
4
4
  title: Node-RED AI Access Plugin API
5
- version: 0.1.0
5
+ version: 0.1.1
6
6
  summary: Temporary, owner-approved Node-RED access for AI clients
7
7
  description: |
8
8
  REST surface exposed by the `@com55/node-red-ai-access` Node-RED plugin
@@ -30,10 +30,16 @@ info:
30
30
 
31
31
  **GET `/mcp/a/<token>/…` never mints tokens.** There is no link-create
32
32
  operation under the get-link surface (no `POST …/links`). Attempted mint
33
- paths return **405** `link_create_not_supported`. Only admins with `*`
33
+ paths return **403** `link_create_not_supported`. Only admins with `*`
34
34
  may create tokens on `POST /mcp/api/v1/links`.
35
35
 
36
- 1. `GET {url}` — markdown docs (workflow, exact paths, errors, limits).
36
+ 1. `GET {url}` — markdown docs (workflow, exact paths, errors, limits),
37
+ served by default as `text/html; charset=utf-8` (the same text escaped in
38
+ a `<pre>`), because some AI web fetchers only read HTML. Every response
39
+ under `/mcp/a/<token>` follows the same rule: `Accept: text/markdown`
40
+ or `application/json`, or `?format=md` / `?format=json`, return the
41
+ raw representation; `Accept: text/plain` returns the raw text as
42
+ `text/plain` (`Vary: Accept`).
37
43
  2. `GET {url}/status`, `/flows`, …
38
44
  3. Layered read (prefer before loading whole tabs):
39
45
  `GET {url}/flows/{id}?summary=1` → `GET {url}/search?q=&flow=` →
@@ -419,6 +425,42 @@ paths:
419
425
  '413':
420
426
  $ref: '#/components/responses/PayloadTooLarge'
421
427
 
428
+ /settings/check-public-url:
429
+ post:
430
+ tags: [Status]
431
+ summary: Check the configured public URL
432
+ description: Probes the MCP endpoint at the supplied HTTPS origin without following redirects.
433
+ operationId: checkPublicUrl
434
+ security:
435
+ - AdminSession: []
436
+ requestBody:
437
+ required: true
438
+ content:
439
+ application/json:
440
+ schema:
441
+ type: object
442
+ required: [publicBaseUrl]
443
+ properties:
444
+ publicBaseUrl:
445
+ type: string
446
+ format: uri
447
+ responses:
448
+ '200':
449
+ description: Probe result
450
+ content:
451
+ application/json:
452
+ schema:
453
+ type: object
454
+ properties:
455
+ ok: { type: boolean }
456
+ status: { type: [integer, 'null'] }
457
+ url: { type: string }
458
+ error: { type: string }
459
+ '400':
460
+ $ref: '#/components/responses/BadRequest'
461
+ '401':
462
+ $ref: '#/components/responses/Unauthorized'
463
+
422
464
  /links:
423
465
  get:
424
466
  tags: [Access links]
@@ -1426,7 +1468,7 @@ components:
1426
1468
  - `invalid_ttl` (400): `ttlSeconds` is zero, negative, not a number, or over the cap for the kind
1427
1469
  - `access_link_limit` (409): Too many active links
1428
1470
  - `access_link_not_found` (404): Unknown link id on revoke
1429
- - `link_create_not_supported` (405): Attempted mint under a GET link
1471
+ - `link_create_not_supported` (403): Attempted mint under a GET link
1430
1472
  - `rate_limited` (429): Per-IP or per-token limit
1431
1473
  - `payload_too_large` (413): Body / proposal over ~1.5MB
1432
1474
  - `invalid_json` (400): Malformed JSON body
@@ -1705,6 +1747,14 @@ components:
1705
1747
  maxActiveLinks:
1706
1748
  type: integer
1707
1749
  minimum: 1
1750
+ publicBaseUrl:
1751
+ type: string
1752
+ format: uri
1753
+ corsAllowedOrigins:
1754
+ type: array
1755
+ items:
1756
+ type: string
1757
+ format: uri
1708
1758
  additionalProperties: false
1709
1759
 
1710
1760
  AiAccessSettingsResponse:
@@ -1752,6 +1802,12 @@ components:
1752
1802
  $ref: '#/components/schemas/DeploymentType'
1753
1803
  maxActiveLinks:
1754
1804
  type: integer
1805
+ publicBaseUrl:
1806
+ type: string
1807
+ corsAllowedOrigins:
1808
+ type: array
1809
+ items:
1810
+ type: string
1755
1811
  sources:
1756
1812
  type: object
1757
1813
  additionalProperties:
@@ -1777,7 +1833,6 @@ components:
1777
1833
  - allowWithoutAdminAuth
1778
1834
  - permission
1779
1835
  - capabilities
1780
- - storageDir
1781
1836
  - defaultLinkTtlSeconds
1782
1837
  - maxActiveLinks
1783
1838
  - defaultDeploymentType
@@ -1807,6 +1862,9 @@ components:
1807
1862
  $ref: '#/components/schemas/Capabilities'
1808
1863
  storageDir:
1809
1864
  type: string
1865
+ description: |
1866
+ Server-side plugin storage directory. Admin sessions only; omitted
1867
+ for access-link (AI token) callers.
1810
1868
  defaultLinkTtlSeconds:
1811
1869
  type: integer
1812
1870
  maxActiveLinks:
@@ -1859,7 +1917,7 @@ components:
1859
1917
  proposals:create, proposals:read-own)
1860
1918
  baseUrls:
1861
1919
  type: object
1862
- required: [api, rpc, openapi, discovery]
1920
+ required: [api, rpc, openapi, openapiJson, discovery]
1863
1921
  properties:
1864
1922
  api:
1865
1923
  type: string
@@ -1867,6 +1925,8 @@ components:
1867
1925
  type: string
1868
1926
  openapi:
1869
1927
  type: string
1928
+ openapiJson:
1929
+ type: string
1870
1930
  discovery:
1871
1931
  type: string
1872
1932
 
package/docs/errors.md CHANGED
@@ -25,7 +25,7 @@ GET-link `401` responses include a `WWW-Authenticate: Bearer …` header.
25
25
  | `invalid_ttl` | 400 | `ttlSeconds` is zero, negative, not a number, or over the cap for the kind | ttlSeconds must be a positive number of seconds within the cap for the token kind: get_link ≤ 86400 (24h); rest and mcp ≤ 2592000 (30 days). | admin |
26
26
  | `access_link_limit` | 409 | Too many active links | Revoke an existing link first | admin |
27
27
  | `access_link_not_found` | 404 | Unknown link id on revoke | List links and use an existing id | admin |
28
- | `link_create_not_supported` | 405 | Attempted mint under a GET link | Admin mints via `POST /mcp/api/v1/links` only | GET |
28
+ | `link_create_not_supported` | 403 | Attempted mint under a GET link | Admin mints via `POST /mcp/api/v1/links` only | GET |
29
29
  | `rate_limited` | 429 | Per-IP or per-token limit | Wait `retryAfter` / `Retry-After` | GET, REST, MCP |
30
30
  | `payload_too_large` | 413 | Body / proposal over ~1.5MB | Shrink content | GET, REST, MCP |
31
31
  | `invalid_json` | 400 | Malformed JSON body | Fix Content-Type / body | REST, MCP |
@@ -1,5 +1,39 @@
1
1
  # MCP client setup
2
2
 
3
+ ## OAuth (for clients with OAuth discovery)
4
+
5
+ Enable MCP OAuth in the AI Access editor sidebar and set a public HTTPS origin
6
+ in **Public URL**. Give the client the MCP endpoint
7
+ `https://YOUR-NODE-RED/mcp/rpc`. It can discover the authorization server from
8
+ the protected resource metadata, register itself, and request `read` or
9
+ `propose` scope. Sign in to Node-RED and approve the client on the consent page.
10
+ The sidebar lists registered clients and lets an admin revoke them and their
11
+ tokens. Localhost HTTP is supported for development. If `httpNodeRoot` is
12
+ disabled, see the sidebar warning for the metadata fallback path.
13
+
14
+ Notes:
15
+
16
+ - Redirect URIs must use `https://`, or `http://` on a loopback host
17
+ (`localhost`, `127.0.0.1`, `[::1]`). Custom native-app schemes such as
18
+ `myapp://callback` are not supported.
19
+ - The consent page uses your Node-RED editor login from the same browser (the
20
+ editor keeps its token in browser storage, not a cookie). Open the editor on
21
+ the same origin as the Public URL and sign in first; the consent page offers a
22
+ Retry button after you sign in.
23
+ - Users with read-only Node-RED permission can approve only the `read` scope;
24
+ `propose` requires full (`*`) permission.
25
+ - Clients that register but are never approved are removed after one hour and do
26
+ not count toward the client limit.
27
+ - Refresh tokens are sliding: each refresh renews the idle lifetime. A token
28
+ chain also has a maximum lifetime of 90 days from approval by default; after
29
+ that the client must sign in again. Change it in the sidebar (0 = unlimited).
30
+ Lowering the maximum applies immediately to existing token chains. Raising it
31
+ or setting 0 does not extend the refresh token a client already holds; the new
32
+ limit takes effect from that chain's next successful refresh (and for new
33
+ chains).
34
+
35
+ ## Access-link bearer tokens
36
+
3
37
  Use an **mcp** access-link token from `/mcp` → Access links (kind = MCP token).
4
38
  Endpoint: `POST https://YOUR-NODE-RED/mcp/rpc` with
5
39
  `Authorization: Bearer <mcp-token>`.
@@ -0,0 +1,82 @@
1
+ # Releasing
2
+
3
+ Releases are published by the `Release` workflow
4
+ (`.github/workflows/release.yml`). Pushing a `v*` tag runs quality checks, the
5
+ production build and tests. It then publishes the package to npm and creates or
6
+ updates the GitHub release for that tag, with the CHANGELOG section as notes and
7
+ the `npm pack` tarball attached.
8
+
9
+ ## Cut a release
10
+
11
+ 1. Bump the version (no tag yet):
12
+
13
+ ```sh
14
+ npm version 0.1.1 --no-git-tag-version
15
+ ```
16
+
17
+ 2. In `CHANGELOG.md`, rename `## [Unreleased]` to `## [0.1.1] - YYYY-MM-DD` (or
18
+ add that section) and summarise the changes. The workflow uses this section
19
+ as the release notes and fails if it is missing.
20
+ 3. Commit, open a PR, and merge it to `main` once CI passes.
21
+ 4. Tag the merge commit and push the tag:
22
+
23
+ ```sh
24
+ git checkout main && git pull
25
+ git tag -a v0.1.1 -m "v0.1.1"
26
+ git push origin v0.1.1
27
+ ```
28
+
29
+ The workflow fails early if the tag does not equal `v` + the `package.json`
30
+ version. If that version is already on npm, the publish step is skipped and only
31
+ the GitHub release is updated. Re-running the workflow is safe.
32
+
33
+ ### Re-run for an existing tag
34
+
35
+ Use **Actions → Release → Run workflow** and enter the tag (for example
36
+ `v0.1.0`). The workflow checks out that tag and runs the same steps. Use this to
37
+ publish a version that was tagged before this workflow existed.
38
+
39
+ ## npm authentication
40
+
41
+ The publish step supports two modes:
42
+
43
+ - **Trusted publishing (OIDC, preferred).** No secret is needed. The job has
44
+ `permissions: id-token: write` and uses Node 24 with the latest npm (trusted
45
+ publishing needs npm >= 11.5.1 and Node >= 22.14.0). Provenance is generated
46
+ automatically for a public package published from a public repository;
47
+ `--provenance` is also passed.
48
+ - **Token fallback.** If the repository secret `NPM_TOKEN` is set, it is used as
49
+ `NODE_AUTH_TOKEN` instead. Use this for the first publish, because a trusted
50
+ publisher can only be configured on a package that already exists on npm.
51
+
52
+ ### First publish
53
+
54
+ 1. On npmjs.com, create a granular access token with read and write access to
55
+ publish packages for the `@com55` scope. It needs "bypass 2FA" if your
56
+ account requires 2FA for writes, and a short expiry.
57
+ 2. Add it to the GitHub repository as the Actions secret `NPM_TOKEN` (**Settings
58
+ → Secrets and variables → Actions**).
59
+ 3. Push the tag, or run the workflow manually with the existing tag.
60
+
61
+ ### Switch to trusted publishing
62
+
63
+ After the package exists on npm:
64
+
65
+ 1. Open npmjs.com → the package → **Settings → Trusted publishing**, and choose
66
+ **GitHub Actions**.
67
+ 2. Enter:
68
+ - Organization or user: `com55`
69
+ - Repository: `node-red-ai-access`
70
+ - Workflow filename: `release.yml` (the filename only, with the extension)
71
+ - Environment: leave empty
72
+ - Allowed actions: enable direct `npm publish`. The workflow runs
73
+ `npm publish`, not `npm stage publish`.
74
+ 3. Complete a publish through the workflow within 2 days, or the new
75
+ configuration expires and has to be recreated.
76
+ 4. Delete the `NPM_TOKEN` secret and revoke the token on npmjs.com.
77
+ 5. Optional: under the package's **Settings → Publishing access**, choose
78
+ "Require two-factor authentication and disallow tokens". Trusted publishing
79
+ keeps working.
80
+
81
+ `repository.url` in `package.json` must match this GitHub repository exactly, or
82
+ OIDC publishing is rejected.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@com55/node-red-ai-access",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "Node-RED plugin that gives AI agents temporary, owner-approved access via REST and MCP",
5
5
  "main": "dist/plugin.cjs",
6
6
  "types": "dist/plugin.d.cts",
@@ -82,7 +82,7 @@
82
82
  "author": "com55",
83
83
  "license": "MIT",
84
84
  "dependencies": {
85
- "@modelcontextprotocol/sdk": "^1.31.0",
85
+ "@modelcontextprotocol/sdk": "^1.32.2",
86
86
  "express": "^5.3.0",
87
87
  "express-rate-limit": "^8.5.2",
88
88
  "yaml": "^2.8.0",
@@ -99,18 +99,18 @@
99
99
  "eslint-plugin-import": "^2.32.0",
100
100
  "husky": "^9.1.7",
101
101
  "lint-staged": "^17.6.0",
102
- "node-red": "4.1.12",
102
+ "node-red": "4.1.17",
103
103
  "prettier": "^3.9.10",
104
104
  "rimraf": "^6.1.3",
105
105
  "supertest": "^7.3.1",
106
106
  "tsup": "^8.5.1",
107
107
  "typescript": "^5.9.3",
108
108
  "typescript-eslint": "^8.71.1",
109
- "vite": "^6.4.0",
109
+ "vite": "^8.3.5",
110
110
  "vitest": "^5.0.3"
111
111
  },
112
112
  "engines": {
113
- "node": ">=20.0.0",
113
+ "node": ">=20.19.0",
114
114
  "npm": ">=10.0.0"
115
115
  },
116
116
  "repository": {