@zudojs/permissions 1.2.0 → 1.3.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/README.md +56 -17
- package/dist/evaluator/authorizationEngine.d.ts +38 -2
- package/dist/evaluator/authorizationEngine.js +35 -2
- package/dist/evaluator/engineSupport/evaluator.cacheKey.d.ts +4 -0
- package/dist/evaluator/engineSupport/evaluator.cacheKey.js +15 -1
- package/dist/evaluator/evaluator.pipeline.d.ts +6 -1
- package/dist/permission/permissionRegistry.d.ts +14 -1
- package/dist/permission/permissionRegistry.js +15 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -135,11 +135,15 @@ Conditions read request-scoped facts from `context.metadata`, supplied per
|
|
|
135
135
|
check:
|
|
136
136
|
|
|
137
137
|
```typescript
|
|
138
|
-
import {
|
|
138
|
+
import { createContextManager, getDefaultStorage } from "@zudojs/tenancy";
|
|
139
|
+
|
|
140
|
+
const tenancy = createContextManager({ storage: getDefaultStorage() });
|
|
139
141
|
|
|
140
142
|
await engine.can(actor, "invoice:read", invoice, {
|
|
141
143
|
// The *verified* tenant — resolved and trust-checked by @zudojs/tenancy.
|
|
142
|
-
|
|
144
|
+
// `requireCurrentTenant()` is a method on the context manager; it throws
|
|
145
|
+
// when no tenant context is active, rather than returning undefined.
|
|
146
|
+
metadata: { tenantId: tenancy.requireCurrentTenant().id },
|
|
143
147
|
});
|
|
144
148
|
```
|
|
145
149
|
|
|
@@ -202,7 +206,11 @@ roles.define({ name: "auditor", permissions: ["audit:read"] });
|
|
|
202
206
|
roles.remove("reader");
|
|
203
207
|
// both take effect on the next check: the engine subscribes to the registry
|
|
204
208
|
|
|
205
|
-
policies.define({
|
|
209
|
+
policies.define({
|
|
210
|
+
name: "lockdown",
|
|
211
|
+
permissions: ["*:*"],
|
|
212
|
+
evaluate: () => ({ allowed: false }),
|
|
213
|
+
});
|
|
206
214
|
// enforced by the next check — through the engine or an existing Ability
|
|
207
215
|
|
|
208
216
|
roles.require("auditor"); // throws RoleNotFoundError when unregistered
|
|
@@ -233,11 +241,17 @@ permissions.define("post:write", { implies: ["post:read"] });
|
|
|
233
241
|
|
|
234
242
|
const engine = createPermissionEngine({
|
|
235
243
|
roles,
|
|
236
|
-
expandImplied:
|
|
244
|
+
expandImplied: permissions,
|
|
237
245
|
});
|
|
238
246
|
// An actor granted post:admin now passes post:read.
|
|
239
247
|
```
|
|
240
248
|
|
|
249
|
+
Pass the registry itself rather than a closure over it. The engine subscribes
|
|
250
|
+
to it, so `permissions.remove("post:admin")` — or redefining it without the
|
|
251
|
+
implication — drops the decisions that were cached while it stood. A bare
|
|
252
|
+
`(permission) => permissions.expandImplied(permission)` still works, but it
|
|
253
|
+
cannot announce a change, so an engine given one caches no decisions at all.
|
|
254
|
+
|
|
241
255
|
## Caching
|
|
242
256
|
|
|
243
257
|
```typescript
|
|
@@ -278,6 +292,12 @@ A check is cached only when the key can describe it completely:
|
|
|
278
292
|
instance, a `Map`) is **not cached**;
|
|
279
293
|
- a decision produced by a policy marked `cacheable: false`, or forced by a
|
|
280
294
|
condition that threw, is not stored;
|
|
295
|
+
- an engine with a `roleResolver` or `permissionResolver` and no
|
|
296
|
+
`resolverCacheKey` caches **nothing**: the resolver reads state the key
|
|
297
|
+
cannot describe, so an entry would outlive a grant withdrawn upstream;
|
|
298
|
+
- an engine whose `expandImplied` is a bare function rather than a
|
|
299
|
+
`createPermissionRegistry()` caches **nothing**, for the same reason: a
|
|
300
|
+
revoked implication cannot announce itself;
|
|
281
301
|
- a TTL of `0` or less means "do not cache".
|
|
282
302
|
|
|
283
303
|
`deniedPermissions` is evaluated before the cache is consulted, so a deny
|
|
@@ -298,22 +318,40 @@ const engine = createPermissionEngine({
|
|
|
298
318
|
A resolver that fails is reported through `onError` and the check continues
|
|
299
319
|
fail-closed, rather than throwing out of the authorization path.
|
|
300
320
|
|
|
321
|
+
A resolver reads authorization state the engine does not own and cannot see
|
|
322
|
+
change, and none of it is in the decision-cache key. **A resolver-backed
|
|
323
|
+
engine therefore caches nothing** unless you describe that state with
|
|
324
|
+
`resolverCacheKey`:
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
const engine = createPermissionEngine({
|
|
328
|
+
roles,
|
|
329
|
+
cache: createMemoryPermissionCache(),
|
|
330
|
+
permissionResolver: { resolvePermissions: (actor) => db.rulesFor(actor.id) },
|
|
331
|
+
// Anything that changes when the resolver's answer could change.
|
|
332
|
+
resolverCacheKey: (actor) => db.grantsVersionFor(actor.id),
|
|
333
|
+
});
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Return `undefined` for an actor whose state you cannot describe, and that
|
|
337
|
+
actor's decisions stay uncached.
|
|
338
|
+
|
|
301
339
|
## Failure behaviour
|
|
302
340
|
|
|
303
341
|
Every failure denies:
|
|
304
342
|
|
|
305
|
-
| Situation
|
|
306
|
-
|
|
|
307
|
-
| Unknown role on the actor
|
|
308
|
-
| Malformed permission string
|
|
309
|
-
| Allow condition throws
|
|
310
|
-
| Deny condition throws
|
|
311
|
-
| Malformed rule/policy pattern | rejected at construction or `define`
|
|
312
|
-
| A deny rule applies
|
|
313
|
-
| Policy throws or times out
|
|
314
|
-
| Role inheritance cycle
|
|
315
|
-
| Role source throws
|
|
316
|
-
| `signal` aborted
|
|
343
|
+
| Situation | Result |
|
|
344
|
+
| ----------------------------- | ------------------------------------------------------ |
|
|
345
|
+
| Unknown role on the actor | denied; reported to `onError`; other roles still apply |
|
|
346
|
+
| Malformed permission string | denied, `reason: "invalid_permission"` |
|
|
347
|
+
| Allow condition throws | the allow does not apply |
|
|
348
|
+
| Deny condition throws | denied, `reason: "rule_deny"`; reported to `onError` |
|
|
349
|
+
| Malformed rule/policy pattern | rejected at construction or `define` |
|
|
350
|
+
| A deny rule applies | denied, `reason: "rule_deny"`, even if a policy allows |
|
|
351
|
+
| Policy throws or times out | denied, `reason: "policy_error:<name>"` |
|
|
352
|
+
| Role inheritance cycle | denied; reported to `onError` |
|
|
353
|
+
| Role source throws | denied; reported to `onError` |
|
|
354
|
+
| `signal` aborted | throws `AuthorizationAbortedError` |
|
|
317
355
|
|
|
318
356
|
A value that is not an `Error` — a policy or resolver that throws a string —
|
|
319
357
|
reaches `onError` wrapped in `PolicyError` or `PermissionResolverError`, with
|
|
@@ -380,7 +418,8 @@ const guard = authorize(engine, "post:update", {
|
|
|
380
418
|
extractResource: (context) => loadPost(context.request.getParam?.("id")),
|
|
381
419
|
// The tenant @zudojs/tenancy resolved and trust-checked — never a header.
|
|
382
420
|
extractMetadata: (context) => ({
|
|
383
|
-
tenantId: context.state.get<{ tenantId: string }>("tenancy:context")
|
|
421
|
+
tenantId: context.state.get<{ tenantId: string }>("tenancy:context")
|
|
422
|
+
?.tenantId,
|
|
384
423
|
}),
|
|
385
424
|
onError: (error, source) => logger.warn({ error, source }, "guard denied"),
|
|
386
425
|
});
|
|
@@ -28,6 +28,19 @@ export interface PolicySource {
|
|
|
28
28
|
get(name: string): PermissionPolicyDefinition | undefined;
|
|
29
29
|
subscribe?(listener: () => void): () => void;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Anything the engine will accept as its source of permission implications.
|
|
33
|
+
*
|
|
34
|
+
* A source with `subscribe` (every `createPermissionRegistry()`) is watched
|
|
35
|
+
* like the role and policy registries: revoking an implication drops every
|
|
36
|
+
* decision that was cached while it stood. A bare function cannot announce a
|
|
37
|
+
* change, so an engine given one caches nothing — see
|
|
38
|
+
* {@link PermissionEngineOptions.expandImplied}.
|
|
39
|
+
*/
|
|
40
|
+
export interface ImpliedPermissionSource {
|
|
41
|
+
expandImplied(permission: string): readonly string[];
|
|
42
|
+
subscribe?(listener: () => void): () => void;
|
|
43
|
+
}
|
|
31
44
|
/** Configuration for the permission engine. */
|
|
32
45
|
export interface PermissionEngineOptions {
|
|
33
46
|
/**
|
|
@@ -54,8 +67,31 @@ export interface PermissionEngineOptions {
|
|
|
54
67
|
readonly permissionResolver?: PermissionResolver;
|
|
55
68
|
/** Loads additional roles for an actor from an external source. */
|
|
56
69
|
readonly roleResolver?: RoleResolver;
|
|
57
|
-
/**
|
|
58
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Describes the state a resolver is answering from, so that decisions it
|
|
72
|
+
* influenced can be cached safely.
|
|
73
|
+
*
|
|
74
|
+
* A resolver reads authorization data the engine does not own and cannot
|
|
75
|
+
* see change — a grants table, another service. Nothing about it is in the
|
|
76
|
+
* decision-cache key, so an entry written while the resolver said "allow"
|
|
77
|
+
* kept answering after the grant was withdrawn upstream. Resolver-backed
|
|
78
|
+
* engines therefore **do not cache at all** unless this is supplied.
|
|
79
|
+
*
|
|
80
|
+
* Return a value that changes whenever the resolver's answer for this actor
|
|
81
|
+
* could change — a version column, an `updatedAt` stamp, a grants-table
|
|
82
|
+
* generation. Return `undefined` for an actor whose state cannot be
|
|
83
|
+
* described, and that actor's decisions stay uncached.
|
|
84
|
+
*/
|
|
85
|
+
readonly resolverCacheKey?: (actor: PermissionActor) => string | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* Expands a permission into the permissions it implies.
|
|
88
|
+
*
|
|
89
|
+
* Prefer passing a `createPermissionRegistry()` — the engine subscribes to
|
|
90
|
+
* it, so revoking an implication invalidates the decisions cached under it.
|
|
91
|
+
* A bare function cannot announce a change, so an engine given one caches
|
|
92
|
+
* no decisions rather than serving one from a revoked implication.
|
|
93
|
+
*/
|
|
94
|
+
readonly expandImplied?: ((permission: string) => readonly string[]) | ImpliedPermissionSource;
|
|
59
95
|
/** Emits an event for every completed check, including failures. */
|
|
60
96
|
readonly emitter?: PermissionEventEmitter;
|
|
61
97
|
/** Reports a failure authorization swallowed to stay fail-closed. */
|
|
@@ -20,6 +20,11 @@ function isPolicySource(policies) {
|
|
|
20
20
|
!Array.isArray(policies) &&
|
|
21
21
|
typeof policies.names === "function");
|
|
22
22
|
}
|
|
23
|
+
function isImpliedPermissionSource(source) {
|
|
24
|
+
return (source !== undefined &&
|
|
25
|
+
typeof source !== "function" &&
|
|
26
|
+
typeof source.expandImplied === "function");
|
|
27
|
+
}
|
|
23
28
|
/**
|
|
24
29
|
* Create a permission engine.
|
|
25
30
|
*/
|
|
@@ -99,6 +104,34 @@ export function createPermissionEngine(options) {
|
|
|
99
104
|
options.roles.subscribe?.(invalidateConfiguration);
|
|
100
105
|
}
|
|
101
106
|
policySource?.subscribe?.(invalidateConfiguration);
|
|
107
|
+
// The third registry the README wires in. Without this, revoking an
|
|
108
|
+
// implication left every decision it granted in the cache for the full TTL
|
|
109
|
+
// — `skipCache: true` said "deny" while `can()` kept saying "allow".
|
|
110
|
+
const impliedSource = isImpliedPermissionSource(options?.expandImplied)
|
|
111
|
+
? options.expandImplied
|
|
112
|
+
: undefined;
|
|
113
|
+
const expandImplied = impliedSource
|
|
114
|
+
? (permission) => impliedSource.expandImplied(permission)
|
|
115
|
+
: options?.expandImplied;
|
|
116
|
+
const impliedWatched = impliedSource?.subscribe?.(invalidateConfiguration) !== undefined;
|
|
117
|
+
// Two inputs the cache key cannot describe. An implication source that
|
|
118
|
+
// cannot announce a change, and a resolver reading state the engine does
|
|
119
|
+
// not own, both make a cached allow outlive the grant behind it — so the
|
|
120
|
+
// decision is not cached at all unless the caller closes the gap.
|
|
121
|
+
const impliedUnwatched = expandImplied !== undefined && !impliedWatched;
|
|
122
|
+
const resolverConfigured = options?.permissionResolver !== undefined ||
|
|
123
|
+
options?.roleResolver !== undefined;
|
|
124
|
+
const resolverCacheKey = options?.resolverCacheKey;
|
|
125
|
+
const cacheScope = (actor) => {
|
|
126
|
+
if (impliedUnwatched)
|
|
127
|
+
return undefined;
|
|
128
|
+
if (!resolverConfigured)
|
|
129
|
+
return `g${generation}`;
|
|
130
|
+
if (!resolverCacheKey)
|
|
131
|
+
return undefined;
|
|
132
|
+
const scope = resolverCacheKey(actor);
|
|
133
|
+
return scope === undefined ? undefined : `g${generation}|r${scope}`;
|
|
134
|
+
};
|
|
102
135
|
// One live view over the configuration. `policies` is a getter so a
|
|
103
136
|
// registry-backed engine re-reads the registry on every evaluation — an
|
|
104
137
|
// Ability used to capture a snapshot of the policy list when it was
|
|
@@ -116,9 +149,9 @@ export function createPermissionEngine(options) {
|
|
|
116
149
|
cacheTtlMs: options?.cacheTtlMs,
|
|
117
150
|
permissionResolver: options?.permissionResolver,
|
|
118
151
|
roleResolver: options?.roleResolver,
|
|
119
|
-
expandImplied
|
|
152
|
+
expandImplied,
|
|
120
153
|
onError: options?.onError,
|
|
121
|
-
cacheScope
|
|
154
|
+
cacheScope,
|
|
122
155
|
};
|
|
123
156
|
const evaluatorOptions = () => liveOptions;
|
|
124
157
|
const emitter = options?.emitter;
|
|
@@ -19,6 +19,10 @@ import type { EvaluatorOptions } from "../evaluator.pipeline.js";
|
|
|
19
19
|
* in the key as a digest; an actor the digest cannot describe is not cached.
|
|
20
20
|
* - The engine's configuration generation is in the key, so a role removed
|
|
21
21
|
* from a live registry cannot be served from an entry written before.
|
|
22
|
+
* - An external resolver reads state the key knows nothing about, so a
|
|
23
|
+
* resolver-backed engine is uncacheable unless it supplies
|
|
24
|
+
* `resolverCacheKey`. So is one whose implication source cannot announce a
|
|
25
|
+
* change.
|
|
22
26
|
*/
|
|
23
27
|
export declare function decisionCacheKey(actor: PermissionActor, permissionStr: string, resource: unknown, options: EvaluatorOptions, authOptions?: AuthorizationOptions): string | undefined;
|
|
24
28
|
//# sourceMappingURL=evaluator.cacheKey.d.ts.map
|
|
@@ -38,6 +38,10 @@ function resourceIdOf(resource) {
|
|
|
38
38
|
* in the key as a digest; an actor the digest cannot describe is not cached.
|
|
39
39
|
* - The engine's configuration generation is in the key, so a role removed
|
|
40
40
|
* from a live registry cannot be served from an entry written before.
|
|
41
|
+
* - An external resolver reads state the key knows nothing about, so a
|
|
42
|
+
* resolver-backed engine is uncacheable unless it supplies
|
|
43
|
+
* `resolverCacheKey`. So is one whose implication source cannot announce a
|
|
44
|
+
* change.
|
|
41
45
|
*/
|
|
42
46
|
export function decisionCacheKey(actor, permissionStr, resource, options, authOptions) {
|
|
43
47
|
if (!options.cache || authOptions?.skipCache === true)
|
|
@@ -50,7 +54,17 @@ export function decisionCacheKey(actor, permissionStr, resource, options, authOp
|
|
|
50
54
|
const digest = actorCacheDigest(actor);
|
|
51
55
|
if (digest === undefined)
|
|
52
56
|
return undefined;
|
|
53
|
-
|
|
57
|
+
// An engine that cannot describe its own configuration for this actor
|
|
58
|
+
// returns `undefined` here — an external resolver with no
|
|
59
|
+
// `resolverCacheKey`, or an implication source that cannot announce a
|
|
60
|
+
// change. Both would otherwise keep answering from a revoked grant.
|
|
61
|
+
let generation = "";
|
|
62
|
+
if (options.cacheScope !== undefined) {
|
|
63
|
+
const scope = options.cacheScope(actor);
|
|
64
|
+
if (scope === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
generation = scope;
|
|
67
|
+
}
|
|
54
68
|
return permissionCacheKey(actor.id, permissionStr, resourceId, `${generation}${digest}`);
|
|
55
69
|
}
|
|
56
70
|
//# sourceMappingURL=evaluator.cacheKey.js.map
|
|
@@ -35,8 +35,13 @@ export interface EvaluatorOptions {
|
|
|
35
35
|
* Extra decision-cache key scope, read on every evaluation. The engine
|
|
36
36
|
* passes its configuration generation, so a role change invalidates every
|
|
37
37
|
* entry written before it.
|
|
38
|
+
*
|
|
39
|
+
* Returning `undefined` means this decision must not be cached: the engine
|
|
40
|
+
* uses that for the inputs the key cannot describe — an implication source
|
|
41
|
+
* that cannot announce a change, and an external resolver with no
|
|
42
|
+
* `resolverCacheKey`.
|
|
38
43
|
*/
|
|
39
|
-
readonly cacheScope?: () => string;
|
|
44
|
+
readonly cacheScope?: (actor: PermissionActor) => string | undefined;
|
|
40
45
|
}
|
|
41
46
|
/** The permissions and rules an actor holds, once everything is resolved. */
|
|
42
47
|
export interface ResolvedGrants {
|
|
@@ -39,6 +39,16 @@ export interface PermissionRegistry {
|
|
|
39
39
|
expandImplied(permission: string): readonly string[];
|
|
40
40
|
remove(permission: string): boolean;
|
|
41
41
|
clear(): void;
|
|
42
|
+
/**
|
|
43
|
+
* Be told whenever the permission set changes (`define`, a `remove` that
|
|
44
|
+
* removed something, `clear`). Returns an unsubscribe function.
|
|
45
|
+
*
|
|
46
|
+
* An engine given this registry as its `expandImplied` source subscribes
|
|
47
|
+
* itself, which is what makes revoking an implication take effect
|
|
48
|
+
* immediately: the engine's cached decisions were written under the old
|
|
49
|
+
* implication and would otherwise keep granting for the whole TTL.
|
|
50
|
+
*/
|
|
51
|
+
subscribe(listener: () => void): () => void;
|
|
42
52
|
}
|
|
43
53
|
/**
|
|
44
54
|
* Create a permission registry.
|
|
@@ -49,7 +59,10 @@ export interface PermissionRegistry {
|
|
|
49
59
|
*
|
|
50
60
|
* const engine = createPermissionEngine({
|
|
51
61
|
* roles,
|
|
52
|
-
*
|
|
62
|
+
* // Pass the registry itself, not a closure over it: the engine
|
|
63
|
+
* // subscribes, so revoking an implication drops the cached decisions
|
|
64
|
+
* // that were made under it.
|
|
65
|
+
* expandImplied: permissions,
|
|
53
66
|
* });
|
|
54
67
|
* ```
|
|
55
68
|
*/
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* @module permission/permissionRegistry
|
|
5
5
|
*/
|
|
6
6
|
import { DuplicatePermissionError, InvalidPermissionError, PermissionNotFoundError, } from "../permissionErrors/index.js";
|
|
7
|
+
import { createChangeNotifier } from "../utils/utils.notifier.js";
|
|
7
8
|
import { formatPermission, isValidPermission, matchesPermission, parsePermission, } from "./permission.core.js";
|
|
8
9
|
/**
|
|
9
10
|
* Create a permission registry.
|
|
@@ -14,13 +15,17 @@ import { formatPermission, isValidPermission, matchesPermission, parsePermission
|
|
|
14
15
|
*
|
|
15
16
|
* const engine = createPermissionEngine({
|
|
16
17
|
* roles,
|
|
17
|
-
*
|
|
18
|
+
* // Pass the registry itself, not a closure over it: the engine
|
|
19
|
+
* // subscribes, so revoking an implication drops the cached decisions
|
|
20
|
+
* // that were made under it.
|
|
21
|
+
* expandImplied: permissions,
|
|
18
22
|
* });
|
|
19
23
|
* ```
|
|
20
24
|
*/
|
|
21
25
|
export function createPermissionRegistry(options) {
|
|
22
26
|
const permissions = new Map();
|
|
23
27
|
const allowOverride = options?.allowOverride ?? false;
|
|
28
|
+
const changes = createChangeNotifier();
|
|
24
29
|
function keyOf(permission) {
|
|
25
30
|
if (typeof permission === "string") {
|
|
26
31
|
// Validate strings and structured values alike; a structured value
|
|
@@ -53,6 +58,7 @@ export function createPermissionRegistry(options) {
|
|
|
53
58
|
? Object.freeze([...defineOptions.implies])
|
|
54
59
|
: undefined,
|
|
55
60
|
});
|
|
61
|
+
changes.notify();
|
|
56
62
|
},
|
|
57
63
|
get(permission) {
|
|
58
64
|
return permissions.get(permission)?.parsed;
|
|
@@ -96,10 +102,17 @@ export function createPermissionRegistry(options) {
|
|
|
96
102
|
return [...expanded];
|
|
97
103
|
},
|
|
98
104
|
remove(permission) {
|
|
99
|
-
|
|
105
|
+
const removed = permissions.delete(permission);
|
|
106
|
+
if (removed)
|
|
107
|
+
changes.notify();
|
|
108
|
+
return removed;
|
|
100
109
|
},
|
|
101
110
|
clear() {
|
|
102
111
|
permissions.clear();
|
|
112
|
+
changes.notify();
|
|
113
|
+
},
|
|
114
|
+
subscribe(listener) {
|
|
115
|
+
return changes.subscribe(listener);
|
|
103
116
|
},
|
|
104
117
|
};
|
|
105
118
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/permissions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Generic authorization engine with RBAC, ABAC, resource authorization, wildcards, role hierarchy, policies, and abilities.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"!dist/.tsbuildinfo"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@zudojs/errors": "1.
|
|
29
|
+
"@zudojs/errors": "1.2.0"
|
|
30
30
|
},
|
|
31
31
|
"engines": {
|
|
32
32
|
"node": ">=24.0.0"
|