@entifix/authz 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/values/guarded-command.d.ts +2 -2
- package/dist/values/guarded-command.js.map +1 -1
- package/dist/values/guarded-nav.d.ts +3 -3
- package/dist/values/guarded-nav.js.map +1 -1
- package/dist/values/permission.d.ts +2 -2
- package/dist/values/permission.js +2 -2
- package/dist/values/permission.js.map +1 -1
- package/package.json +2 -5
|
@@ -3,8 +3,8 @@ import type { Permission } from './permission.js';
|
|
|
3
3
|
* A deterministic command plus the permission it requires, if any.
|
|
4
4
|
*
|
|
5
5
|
* The sibling of {@link GuardedNavItem}, and here for the identical reason: this
|
|
6
|
-
* package is the only vocabulary a
|
|
7
|
-
*
|
|
6
|
+
* package is the only vocabulary a shell and the host application both reach,
|
|
7
|
+
* so a framework shell can contribute commands to whichever host
|
|
8
8
|
* mounts it without either of them importing the other.
|
|
9
9
|
*
|
|
10
10
|
* It is a *separate* type rather than a nav item with a flag because the two
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/values/guarded-command.ts"],"sourcesContent":["import type { Permission } from './permission.js';\n\n/**\n * A deterministic command plus the permission it requires, if any.\n *\n * The sibling of {@link GuardedNavItem}, and here for the identical reason: this\n * package is the only vocabulary a
|
|
1
|
+
{"version":3,"sources":["../../src/values/guarded-command.ts"],"sourcesContent":["import type { Permission } from './permission.js';\n\n/**\n * A deterministic command plus the permission it requires, if any.\n *\n * The sibling of {@link GuardedNavItem}, and here for the identical reason: this\n * package is the only vocabulary a shell and the host application both reach,\n * so a framework shell can contribute commands to whichever host\n * mounts it without either of them importing the other.\n *\n * It is a *separate* type rather than a nav item with a flag because the two\n * answer different questions. A nav item is a place you can be; a command is\n * something you can do, and it is the thing that has keywords, a page and no\n * sidebar row. Folding them would put `keywordsKey` on every destination and\n * `type` on every command.\n *\n * `labelKey`/`keywordsKey` are **namespace-qualified catalog keys**, not copy —\n * whoever renders the palette does not own the wording, and spelling the\n * namespace out is also what makes an `app:` key visible to the lint rule that\n * keeps those inside `apps/`.\n *\n * Filtering on `permission`/`entitled` is **presentation only**, exactly as it\n * is for navigation: the route behind the command is what refuses the request.\n */\nexport interface GuardedCommand {\n /** Stable id, unique across every contributing shell. Also the recency key. */\n key: string;\n labelKey: string;\n /**\n * A catalog key whose value is a **comma-separated** list of extra terms this\n * command answers to — how a Spanish command still matches the English word\n * someone typed. `parseKeywords` in `@entifix/core` owns the split.\n */\n keywordsKey?: string;\n /** Locale-free, like every other internal href; the renderer prefixes it. */\n href: string;\n /**\n * Which palette page this command sits on. Omitted means the root.\n *\n * Depth is a stack of pages rather than a nested prefix, so a group of related\n * commands — every \"new record\" route, say — is one root entry that descends\n * instead of N entries competing with the destinations beside them.\n */\n page?: string;\n /** Omit to show the command to every signed-in user. */\n permission?: Permission;\n /**\n * Also gate on the acting organization's provisioning — ADR 0007's second\n * ceiling — using the domain segment of its own {@link permission}, with all\n * the reasoning {@link GuardedNavItem.entitled} carries. Setting it without a\n * permission is the same declaration error, and fails the same way.\n */\n entitled?: boolean;\n}\n\n/**\n * The page every \"create a record\" command descends into.\n *\n * A shared constant rather than a literal in each shell: the domain shells\n * declare commands onto this page and the palette synthesizes the root entry\n * that opens it, so the two halves have to agree and there is no test that\n * would notice a typo — the page would simply be unreachable, with every\n * command still declared and still granted.\n */\nexport const NEW_COMMAND_PAGE = 'new';\n"],"names":["NEW_COMMAND_PAGE"],"mappings":"AAuDA;;;;;;;;CAQC,GACD,OAAO,MAAMA,mBAAmB,MAAM"}
|
|
@@ -8,8 +8,8 @@ import type { ScreenType } from './screen-type.js';
|
|
|
8
8
|
* behind it must be expressed in the same terms `requirePermission` enforces, and
|
|
9
9
|
* `can` — the filter every layer applies — is declared right next to it.
|
|
10
10
|
*
|
|
11
|
-
* It is also the
|
|
12
|
-
*
|
|
11
|
+
* It is also the one package both a shell and the host application depend on.
|
|
12
|
+
* That matters because a shell contributes its own nav
|
|
13
13
|
* fragment to whichever host mounts it, and shells may not depend on each other.
|
|
14
14
|
*
|
|
15
15
|
* `label`/`title` are **namespace-qualified catalog keys**, not copy. Whoever
|
|
@@ -64,7 +64,7 @@ export interface GuardedNavSection {
|
|
|
64
64
|
* domain ([ADR 0033](../../../../../../docs/adr/0033-the-screen-taxonomy.md)).
|
|
65
65
|
*
|
|
66
66
|
* Every contributing shell declares its own, because this interface is the
|
|
67
|
-
* only thing a
|
|
67
|
+
* only thing a shell and the host application both reach: adding the
|
|
68
68
|
* tier is not an edit in the host.
|
|
69
69
|
*
|
|
70
70
|
* **Optional, and narrowly so.** A section with no type is not a fifth
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/values/guarded-nav.ts"],"sourcesContent":["import type { Permission } from './permission.js';\nimport type { ScreenType } from './screen-type.js';\n\n/**\n * A navigation destination plus the permission it requires, if any.\n *\n * This is the permission-annotated half of navigation, which is why it lives with\n * the authorization vocabulary rather than in a shell: an entry and the route\n * behind it must be expressed in the same terms `requirePermission` enforces, and\n * `can` — the filter every layer applies — is declared right next to it.\n *\n * It is also the
|
|
1
|
+
{"version":3,"sources":["../../src/values/guarded-nav.ts"],"sourcesContent":["import type { Permission } from './permission.js';\nimport type { ScreenType } from './screen-type.js';\n\n/**\n * A navigation destination plus the permission it requires, if any.\n *\n * This is the permission-annotated half of navigation, which is why it lives with\n * the authorization vocabulary rather than in a shell: an entry and the route\n * behind it must be expressed in the same terms `requirePermission` enforces, and\n * `can` — the filter every layer applies — is declared right next to it.\n *\n * It is also the one package both a shell and the host application depend on.\n * That matters because a shell contributes its own nav\n * fragment to whichever host mounts it, and shells may not depend on each other.\n *\n * `label`/`title` are **namespace-qualified catalog keys**, not copy. Whoever\n * renders the list does not necessarily own its wording, so the namespace travels\n * with the key — and spelling it out is also what makes an `app:` key visible to\n * the lint rule that keeps those keys inside `apps/`.\n *\n * Filtering on these is **presentation only**. Hiding an entry protects nothing;\n * the service is what refuses the request.\n */\nexport interface GuardedNavItem {\n label: string;\n href: string;\n icon?: string;\n /**\n * When set, the item also offers \"Open in workspace\" — a link to\n * `/workspace?tab=<workspace>`.\n */\n workspace?: string;\n /** Omit to show the item to every signed-in user. */\n permission?: Permission;\n /**\n * Also gate this item on the acting organization's provisioning — ADR 0007's\n * second ceiling — using the domain segment of its own {@link permission}.\n *\n * A boolean rather than a domain name, because the domain is already written\n * one line up: a second string here could disagree with the permission the\n * route behind it actually checks, which is the drift this whole interface\n * exists to prevent.\n *\n * **Omitted is the right answer for most items.** An organization is not\n * \"provisioned for\" `catalog-reference`, `config` or `authn` — those are\n * operator-owned, and `catalog-reference` is explicitly never grantable\n * (ADR 0022). Setting this on one of them would hide the platform's own\n * vocabulary from every vendor.\n *\n * Setting it without a `permission` is a declaration error and throws: there\n * would be no domain to read, and both possible defaults — always shown,\n * always hidden — read as a bug somewhere else entirely.\n *\n * A session with **no** organization ignores this flag: the ceiling is a\n * property of an organization, so an operator is outside it rather than\n * refused by it.\n */\n entitled?: boolean;\n}\n\nexport interface GuardedNavSection {\n title?: string;\n items: GuardedNavItem[];\n /**\n * What shape these screens are — the top tier of the sidebar, above the\n * domain ([ADR 0033](../../../../../../docs/adr/0033-the-screen-taxonomy.md)).\n *\n * Every contributing shell declares its own, because this interface is the\n * only thing a shell and the host application both reach: adding the\n * tier is not an edit in the host.\n *\n * **Optional, and narrowly so.** A section with no type is not a fifth\n * category, it is a section that is not a screen group at all — the account\n * surface is the one case, and it carries no permission for the same reason.\n * A second untyped section appearing without a written reason means the\n * taxonomy is missing a case.\n */\n type?: ScreenType;\n}\n"],"names":[],"mappings":"AA4DA,WAkBC"}
|
|
@@ -4,8 +4,8 @@ import { type Entity, type EntityAction, type EntityConstructor, type UseCaseCon
|
|
|
4
4
|
*
|
|
5
5
|
* Defined in `@entifix/core` and aliased here, not duplicated: a UI
|
|
6
6
|
* reads the same triple off a served `EntityMetadataDocument` to decide whether
|
|
7
|
-
* to render Save and Delete, and
|
|
8
|
-
*
|
|
7
|
+
* to render Save and Delete, and a T3 package may not import this one, which
|
|
8
|
+
* is T4. Aliasing keeps one definition and leaves every call site below
|
|
9
9
|
* unchanged.
|
|
10
10
|
*/
|
|
11
11
|
export declare const Actions: readonly ["read", "write", "delete"];
|
|
@@ -4,8 +4,8 @@ import { describeEntityUseCases, EntifixBuildError, ENTITY_ACTIONS, extractMetaE
|
|
|
4
4
|
*
|
|
5
5
|
* Defined in `@entifix/core` and aliased here, not duplicated: a UI
|
|
6
6
|
* reads the same triple off a served `EntityMetadataDocument` to decide whether
|
|
7
|
-
* to render Save and Delete, and
|
|
8
|
-
*
|
|
7
|
+
* to render Save and Delete, and a T3 package may not import this one, which
|
|
8
|
+
* is T4. Aliasing keeps one definition and leaves every call site below
|
|
9
9
|
* unchanged.
|
|
10
10
|
*/ export const Actions = ENTITY_ACTIONS;
|
|
11
11
|
const SEGMENT_COUNT = 3;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/values/permission.ts"],"sourcesContent":["import {\n describeEntityUseCases,\n EntifixBuildError,\n type Entity,\n ENTITY_ACTIONS,\n type EntityAction,\n type EntityConstructor,\n extractMetaEntity,\n extractMetaUseCaseBinding,\n type UseCaseConstructor,\n} from '@entifix/core';\n\n/**\n * What a caller wants to do with a resource.\n *\n * Defined in `@entifix/core` and aliased here, not duplicated: a UI\n * reads the same triple off a served `EntityMetadataDocument` to decide whether\n * to render Save and Delete, and
|
|
1
|
+
{"version":3,"sources":["../../src/values/permission.ts"],"sourcesContent":["import {\n describeEntityUseCases,\n EntifixBuildError,\n type Entity,\n ENTITY_ACTIONS,\n type EntityAction,\n type EntityConstructor,\n extractMetaEntity,\n extractMetaUseCaseBinding,\n type UseCaseConstructor,\n} from '@entifix/core';\n\n/**\n * What a caller wants to do with a resource.\n *\n * Defined in `@entifix/core` and aliased here, not duplicated: a UI\n * reads the same triple off a served `EntityMetadataDocument` to decide whether\n * to render Save and Delete, and a T3 package may not import this one, which\n * is T4. Aliasing keeps one definition and leaves every call site below\n * unchanged.\n */\nexport const Actions = ENTITY_ACTIONS;\nexport type Action = EntityAction;\n\n/**\n * A verb declared by `@useCase()` — `publish`, `revoke-sessions`, `approve`.\n *\n * Per-entity and open, rather than one global union every domain appends to:\n * that would make an unrelated domain's verb assignable everywhere and grow\n * without bound. The check that a verb exists is therefore at runtime, in\n * {@link permissionForUseCase}, and in the source scan `@r10c/slices` runs\n * ([ADR 0026](../../../../../docs/adr/0026-the-use-case-descriptor-and-served-entity-metadata.md)).\n */\nexport type UseCaseVerb = string;\n\n/**\n * The third segment of a permission: the CRUD triple, or a use-case verb.\n *\n * `Action` stays the narrow three-value union, and every place that guards CRUD\n * keeps using it. This wider alias exists only where a segment has already been\n * parsed off a string and could legitimately be either.\n */\nexport type PermissionAction = Action | UseCaseVerb;\n\n/**\n * A permission is three colon-separated segments — `<domain>:<entityKey>:<action>`\n * — so it can be derived from an entity's own `@entity({ domain, key })`\n * metadata rather than maintained as a parallel list. `*` in any segment of a\n * **granted** permission is a wildcard (see {@link permissionMatches}); a\n * **required** permission is always concrete.\n */\nexport type Permission = `${string}:${string}:${string}`;\n\n/** The `<domain>:<entityKey>` half of a permission — what is being acted on. */\nexport type Resource = `${string}:${string}`;\n\nconst SEGMENT_COUNT = 3;\nconst WILDCARD = '*';\n\n/** Build a permission from its parts. `*` is a legal action on a grant. */\nexport const permissionFor = (\n domain: string,\n entityKey: string,\n action: PermissionAction,\n): Permission => `${domain}:${entityKey}:${action}`;\n\n/** Join a resource and an action into the permission that guards it. */\nexport const permissionOf = (\n resource: Resource,\n action: PermissionAction,\n): Permission => `${resource}:${action}`;\n\n/**\n * Derive the permission guarding an entity from the entity itself. This is the\n * point of the `<domain>:<key>:<action>` shape: making a new entity guardable\n * needs no new vocabulary, only its existing `@entity()` metadata.\n */\nexport const permissionForEntity = <T extends Entity>(\n entityConstructor: EntityConstructor<T>,\n action: Action,\n): Permission => {\n const meta = extractMetaEntity(entityConstructor);\n if (meta.domain === undefined || meta.key === undefined) {\n throw new EntifixBuildError(\n `Entity ${entityConstructor.name} needs both a domain and a key to derive a permission`,\n );\n }\n return permissionFor(meta.domain, meta.key, action);\n};\n\n/**\n * Derive the permission guarding a use case.\n *\n * The **one-argument** form is what call sites use: a `@useCase()` class knows\n * both the entity it acts on and its own verb, so the verb string is written\n * once — in the decorator — and every guard imports the derived constant instead\n * of retyping it. The **two-argument** form is for a caller that holds only the\n * entity, which is the `$metadata` route walking an entity's descriptors.\n *\n * Both check that the entity really declares the verb, so a typo throws at\n * module load rather than silently denying every request. That check is at\n * runtime because compile-time narrowing does not survive the decorator\n * boundary: `Symbol.metadata` erases types and `extractMetaEntity` hands back an\n * untyped `MetaEntity`. Nothing is lost that existed — `parsePermission` looks\n * like it validates the action segment and does not.\n */\nexport function permissionForUseCase(useCase: UseCaseConstructor): Permission;\nexport function permissionForUseCase<T extends Entity>(\n entityConstructor: EntityConstructor<T>,\n key: UseCaseVerb,\n): Permission;\nexport function permissionForUseCase(\n target: UseCaseConstructor | EntityConstructor<Entity>,\n key?: UseCaseVerb,\n): Permission {\n const binding =\n key === undefined\n ? extractMetaUseCaseBinding(target as UseCaseConstructor)\n : { entity: target as EntityConstructor<Entity>, key };\n\n const meta = extractMetaEntity(binding.entity);\n if (meta.domain === undefined || meta.key === undefined) {\n throw new EntifixBuildError(\n `Entity ${binding.entity.name} needs both a domain and a key to derive a permission`,\n );\n }\n const declares = describeEntityUseCases(binding.entity).some(\n useCase => useCase.key === binding.key,\n );\n if (!declares) {\n throw new EntifixBuildError(\n `Entity ${binding.entity.name} does not declare the use case \"${binding.key}\"`,\n );\n }\n return permissionFor(meta.domain, meta.key, binding.key);\n}\n\n/**\n * Split a permission back into the resource + action a policy decision reads.\n * Throws on anything that is not exactly three segments, so a typo in a guard\n * fails at the call site instead of silently denying every request.\n */\nexport const parsePermission = (\n permission: Permission,\n): { resource: Resource; action: PermissionAction } => {\n const segments = permission.split(':');\n if (segments.length !== SEGMENT_COUNT) {\n throw new EntifixBuildError(\n `Malformed permission \"${permission}\" — expected <domain>:<entityKey>:<action>`,\n );\n }\n // The cast is only the arity the length check above already established. It\n // deliberately does not narrow the action segment: that used to say `Action`,\n // which read as a typo check it never performed, and is now false as well —\n // a use-case verb is a legal third segment.\n const [domain, entityKey, action] = segments as [\n string,\n string,\n PermissionAction,\n ];\n return { resource: `${domain}:${entityKey}`, action };\n};\n\n/**\n * Does a granted permission cover a required one? Compared segment by segment,\n * with `*` on the granted side matching anything. `super-admin`'s `*:*:*` thus\n * covers everything, and `product-configuration-management:*:read` covers every\n * entity in that domain without naming them.\n */\nexport const permissionMatches = (\n granted: Permission,\n required: Permission,\n): boolean => {\n const grantedSegments = granted.split(':');\n const requiredSegments = required.split(':');\n if (\n grantedSegments.length !== SEGMENT_COUNT ||\n requiredSegments.length !== SEGMENT_COUNT\n ) {\n return false;\n }\n return grantedSegments.every(\n (segment, index) =>\n segment === WILDCARD || segment === requiredSegments[index],\n );\n};\n"],"names":["describeEntityUseCases","EntifixBuildError","ENTITY_ACTIONS","extractMetaEntity","extractMetaUseCaseBinding","Actions","SEGMENT_COUNT","WILDCARD","permissionFor","domain","entityKey","action","permissionOf","resource","permissionForEntity","entityConstructor","meta","undefined","key","name","permissionForUseCase","target","binding","entity","declares","some","useCase","parsePermission","permission","segments","split","length","permissionMatches","granted","required","grantedSegments","requiredSegments","every","segment","index"],"mappings":"AAAA,SACEA,sBAAsB,EACtBC,iBAAiB,EAEjBC,cAAc,EAGdC,iBAAiB,EACjBC,yBAAyB,QAEpB,gBAAgB;AAEvB;;;;;;;;CAQC,GACD,OAAO,MAAMC,UAAUH,eAAe;AAmCtC,MAAMI,gBAAgB;AACtB,MAAMC,WAAW;AAEjB,yEAAyE,GACzE,OAAO,MAAMC,gBAAgB,CAC3BC,QACAC,WACAC,SACe,GAAGF,OAAO,CAAC,EAAEC,UAAU,CAAC,EAAEC,QAAQ,CAAC;AAEpD,sEAAsE,GACtE,OAAO,MAAMC,eAAe,CAC1BC,UACAF,SACe,GAAGE,SAAS,CAAC,EAAEF,QAAQ,CAAC;AAEzC;;;;CAIC,GACD,OAAO,MAAMG,sBAAsB,CACjCC,mBACAJ;IAEA,MAAMK,OAAOb,kBAAkBY;IAC/B,IAAIC,KAAKP,MAAM,KAAKQ,aAAaD,KAAKE,GAAG,KAAKD,WAAW;QACvD,MAAM,IAAIhB,kBACR,CAAC,OAAO,EAAEc,kBAAkBI,IAAI,CAAC,qDAAqD,CAAC;IAE3F;IACA,OAAOX,cAAcQ,KAAKP,MAAM,EAAEO,KAAKE,GAAG,EAAEP;AAC9C,EAAE;AAuBF,OAAO,SAASS,qBACdC,MAAsD,EACtDH,GAAiB;IAEjB,MAAMI,UACJJ,QAAQD,YACJb,0BAA0BiB,UAC1B;QAAEE,QAAQF;QAAqCH;IAAI;IAEzD,MAAMF,OAAOb,kBAAkBmB,QAAQC,MAAM;IAC7C,IAAIP,KAAKP,MAAM,KAAKQ,aAAaD,KAAKE,GAAG,KAAKD,WAAW;QACvD,MAAM,IAAIhB,kBACR,CAAC,OAAO,EAAEqB,QAAQC,MAAM,CAACJ,IAAI,CAAC,qDAAqD,CAAC;IAExF;IACA,MAAMK,WAAWxB,uBAAuBsB,QAAQC,MAAM,EAAEE,IAAI,CAC1DC,CAAAA,UAAWA,QAAQR,GAAG,KAAKI,QAAQJ,GAAG;IAExC,IAAI,CAACM,UAAU;QACb,MAAM,IAAIvB,kBACR,CAAC,OAAO,EAAEqB,QAAQC,MAAM,CAACJ,IAAI,CAAC,gCAAgC,EAAEG,QAAQJ,GAAG,CAAC,CAAC,CAAC;IAElF;IACA,OAAOV,cAAcQ,KAAKP,MAAM,EAAEO,KAAKE,GAAG,EAAEI,QAAQJ,GAAG;AACzD;AAEA;;;;CAIC,GACD,OAAO,MAAMS,kBAAkB,CAC7BC;IAEA,MAAMC,WAAWD,WAAWE,KAAK,CAAC;IAClC,IAAID,SAASE,MAAM,KAAKzB,eAAe;QACrC,MAAM,IAAIL,kBACR,CAAC,sBAAsB,EAAE2B,WAAW,0CAA0C,CAAC;IAEnF;IACA,4EAA4E;IAC5E,8EAA8E;IAC9E,4EAA4E;IAC5E,4CAA4C;IAC5C,MAAM,CAACnB,QAAQC,WAAWC,OAAO,GAAGkB;IAKpC,OAAO;QAAEhB,UAAU,GAAGJ,OAAO,CAAC,EAAEC,WAAW;QAAEC;IAAO;AACtD,EAAE;AAEF;;;;;CAKC,GACD,OAAO,MAAMqB,oBAAoB,CAC/BC,SACAC;IAEA,MAAMC,kBAAkBF,QAAQH,KAAK,CAAC;IACtC,MAAMM,mBAAmBF,SAASJ,KAAK,CAAC;IACxC,IACEK,gBAAgBJ,MAAM,KAAKzB,iBAC3B8B,iBAAiBL,MAAM,KAAKzB,eAC5B;QACA,OAAO;IACT;IACA,OAAO6B,gBAAgBE,KAAK,CAC1B,CAACC,SAASC,QACRD,YAAY/B,YAAY+B,YAAYF,gBAAgB,CAACG,MAAM;AAEjE,EAAE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/authz",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Authorization vocabulary: permissions derived from entities, roles, a grant table, and a PolicyDecision port.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,9 +31,6 @@
|
|
|
31
31
|
},
|
|
32
32
|
"nx": {
|
|
33
33
|
"tags": [
|
|
34
|
-
"layer:entifix",
|
|
35
|
-
"scope:shared",
|
|
36
|
-
"entifix:contract",
|
|
37
34
|
"tier:4"
|
|
38
35
|
],
|
|
39
36
|
"sourceRoot": "packages/ts/authz/src",
|
|
@@ -55,7 +52,7 @@
|
|
|
55
52
|
},
|
|
56
53
|
"dependencies": {
|
|
57
54
|
"@swc/helpers": "0.5.23",
|
|
58
|
-
"@entifix/core": "0.1.
|
|
55
|
+
"@entifix/core": "0.1.3"
|
|
59
56
|
},
|
|
60
57
|
"peerDependencies": {
|
|
61
58
|
"effect": ">=3.22 <4"
|