@rt-tools/auth-server 0.0.0-stage → 0.1.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.
Files changed (70) hide show
  1. package/README.md +77 -2
  2. package/cjs/index.js +13 -0
  3. package/cjs/index.js.map +1 -0
  4. package/cjs/lib/access-audit.js +39 -0
  5. package/cjs/lib/access-audit.js.map +1 -0
  6. package/cjs/lib/access.js +36 -0
  7. package/cjs/lib/access.js.map +1 -0
  8. package/cjs/lib/auth-server.module.js +71 -0
  9. package/cjs/lib/auth-server.module.js.map +1 -0
  10. package/cjs/lib/auth.guard.js +51 -0
  11. package/cjs/lib/auth.guard.js.map +1 -0
  12. package/cjs/lib/auth.tokens.js +10 -0
  13. package/cjs/lib/auth.tokens.js.map +1 -0
  14. package/cjs/lib/catalog-sync.js +51 -0
  15. package/cjs/lib/catalog-sync.js.map +1 -0
  16. package/cjs/lib/connect-access.js +54 -0
  17. package/cjs/lib/connect-access.js.map +1 -0
  18. package/cjs/lib/current-caller.decorator.js +14 -0
  19. package/cjs/lib/current-caller.decorator.js.map +1 -0
  20. package/cjs/lib/testing/keys.js +28 -0
  21. package/cjs/lib/testing/keys.js.map +1 -0
  22. package/cjs/lib/token-verifier.js +72 -0
  23. package/cjs/lib/token-verifier.js.map +1 -0
  24. package/cjs/package.json +3 -0
  25. package/esm/index.d.ts +10 -0
  26. package/esm/index.d.ts.map +1 -0
  27. package/esm/index.js +10 -0
  28. package/esm/index.js.map +1 -0
  29. package/esm/lib/access-audit.d.ts +13 -0
  30. package/esm/lib/access-audit.d.ts.map +1 -0
  31. package/esm/lib/access-audit.js +35 -0
  32. package/esm/lib/access-audit.js.map +1 -0
  33. package/esm/lib/access.d.ts +31 -0
  34. package/esm/lib/access.d.ts.map +1 -0
  35. package/esm/lib/access.js +29 -0
  36. package/esm/lib/access.js.map +1 -0
  37. package/esm/lib/auth-server.module.d.ts +28 -0
  38. package/esm/lib/auth-server.module.d.ts.map +1 -0
  39. package/esm/lib/auth-server.module.js +67 -0
  40. package/esm/lib/auth-server.module.js.map +1 -0
  41. package/esm/lib/auth.guard.d.ts +15 -0
  42. package/esm/lib/auth.guard.d.ts.map +1 -0
  43. package/esm/lib/auth.guard.js +48 -0
  44. package/esm/lib/auth.guard.js.map +1 -0
  45. package/esm/lib/auth.tokens.d.ts +7 -0
  46. package/esm/lib/auth.tokens.d.ts.map +1 -0
  47. package/esm/lib/auth.tokens.js +7 -0
  48. package/esm/lib/auth.tokens.js.map +1 -0
  49. package/esm/lib/catalog-sync.d.ts +27 -0
  50. package/esm/lib/catalog-sync.d.ts.map +1 -0
  51. package/esm/lib/catalog-sync.js +48 -0
  52. package/esm/lib/catalog-sync.js.map +1 -0
  53. package/esm/lib/connect-access.d.ts +35 -0
  54. package/esm/lib/connect-access.d.ts.map +1 -0
  55. package/esm/lib/connect-access.js +49 -0
  56. package/esm/lib/connect-access.js.map +1 -0
  57. package/esm/lib/current-caller.decorator.d.ts +6 -0
  58. package/esm/lib/current-caller.decorator.d.ts.map +1 -0
  59. package/esm/lib/current-caller.decorator.js +10 -0
  60. package/esm/lib/current-caller.decorator.js.map +1 -0
  61. package/esm/lib/testing/keys.d.ts +17 -0
  62. package/esm/lib/testing/keys.d.ts.map +1 -0
  63. package/esm/lib/testing/keys.js +23 -0
  64. package/esm/lib/testing/keys.js.map +1 -0
  65. package/esm/lib/token-verifier.d.ts +29 -0
  66. package/esm/lib/token-verifier.d.ts.map +1 -0
  67. package/esm/lib/token-verifier.js +67 -0
  68. package/esm/lib/token-verifier.js.map +1 -0
  69. package/esm/package.json +3 -0
  70. package/package.json +53 -4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../../../../../projects/auth-server/src/lib/testing/keys.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsD,eAAe,EAAW,MAAM,MAAM,CAAC;AAEpG,8BAA8B;AAC9B,eAAO,MAAM,WAAW,EAAE,MAA2C,CAAC;AACtE,eAAO,MAAM,WAAW,EAAE,MAAuB,CAAC;AAElD,iFAAiF;AACjF,MAAM,WAAW,UAAU;IACvB,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACnI;AAED,wBAAsB,SAAS,IAAI,OAAO,CAAC,UAAU,CAAC,CAiBrD;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,GAAG,GAAE,MAAoB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzG"}
@@ -0,0 +1,23 @@
1
+ import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from 'jose';
2
+ /** The realm of the tests. */
3
+ export const TEST_ISSUER = 'http://localhost:58080/realms/rt';
4
+ export const TEST_CLIENT = 'orders-admin';
5
+ export async function testRealm() {
6
+ const own = await generateKeyPair('RS256');
7
+ const foreign = await generateKeyPair('RS256');
8
+ const jwk = { ...(await exportJWK(own.publicKey)), kid: 'own', alg: 'RS256', use: 'sig' };
9
+ return {
10
+ keys: createLocalJWKSet({ keys: [jwk] }),
11
+ sign: async (claims, options = {}) => new SignJWT(claims)
12
+ .setProtectedHeader({ alg: 'RS256', kid: 'own' })
13
+ .setIssuer(options.issuer ?? TEST_ISSUER)
14
+ .setIssuedAt()
15
+ .setExpirationTime(options.expiresIn ?? '5m')
16
+ .sign(options.foreignKey ? foreign.privateKey : own.privateKey),
17
+ };
18
+ }
19
+ /** The claims of a person with the given roles in the test client, issued to it. */
20
+ export function personClaims(roles, azp = TEST_CLIENT) {
21
+ return { sub: 'p-1', email: 'anna@example.com', resource_access: { [TEST_CLIENT]: { roles } }, azp };
22
+ }
23
+ //# sourceMappingURL=keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.js","sourceRoot":"","sources":["../../../../../projects/auth-server/src/lib/testing/keys.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,SAAS,EAAE,eAAe,EAAwB,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpG,8BAA8B;AAC9B,MAAM,CAAC,MAAM,WAAW,GAAW,kCAAkC,CAAC;AACtE,MAAM,CAAC,MAAM,WAAW,GAAW,cAAc,CAAC;AAQlD,MAAM,CAAC,KAAK,UAAU,SAAS;IAC3B,MAAM,GAAG,GAAkB,MAAM,eAAe,CAAC,OAAO,CAAC,CAAC;IAC1D,MAAM,OAAO,GAAkB,MAAM,eAAe,CAAC,OAAO,CAAC,CAAC;IAC9D,MAAM,GAAG,GAAQ,EAAE,GAAG,CAAC,MAAM,SAAS,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;IAC/F,OAAO;QACH,IAAI,EAAE,iBAAiB,CAAC,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;QACxC,IAAI,EAAE,KAAK,EACP,MAA+B,EAC/B,UAAyE,EAAE,EAC5D,EAAE,CACjB,IAAI,OAAO,CAAC,MAAM,CAAC;aACd,kBAAkB,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;aAChD,SAAS,CAAC,OAAO,CAAC,MAAM,IAAI,WAAW,CAAC;aACxC,WAAW,EAAE;aACb,iBAAiB,CAAC,OAAO,CAAC,SAAS,IAAI,IAAI,CAAC;aAC5C,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC;KAC1E,CAAC;AACN,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,YAAY,CAAC,KAAwB,EAAE,MAAc,WAAW;IAC5E,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,kBAAkB,EAAE,eAAe,EAAE,EAAE,CAAC,WAAW,CAAC,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC;AACzG,CAAC","sourcesContent":["import { createLocalJWKSet, exportJWK, generateKeyPair, JWK, JWTVerifyGetKey, SignJWT } from 'jose';\n\n/** The realm of the tests. */\nexport const TEST_ISSUER: string = 'http://localhost:58080/realms/rt';\nexport const TEST_CLIENT: string = 'orders-admin';\n\n/** A realm key pair and the key set the verifier reads, as a realm serves it. */\nexport interface ITestRealm {\n readonly keys: JWTVerifyGetKey;\n sign(claims: Record<string, unknown>, options?: { issuer?: string; expiresIn?: string; foreignKey?: boolean }): Promise<string>;\n}\n\nexport async function testRealm(): Promise<ITestRealm> {\n const own: CryptoKeyPair = await generateKeyPair('RS256');\n const foreign: CryptoKeyPair = await generateKeyPair('RS256');\n const jwk: JWK = { ...(await exportJWK(own.publicKey)), kid: 'own', alg: 'RS256', use: 'sig' };\n return {\n keys: createLocalJWKSet({ keys: [jwk] }),\n sign: async (\n claims: Record<string, unknown>,\n options: { issuer?: string; expiresIn?: string; foreignKey?: boolean } = {}\n ): Promise<string> =>\n new SignJWT(claims)\n .setProtectedHeader({ alg: 'RS256', kid: 'own' })\n .setIssuer(options.issuer ?? TEST_ISSUER)\n .setIssuedAt()\n .setExpirationTime(options.expiresIn ?? '5m')\n .sign(options.foreignKey ? foreign.privateKey : own.privateKey),\n };\n}\n\n/** The claims of a person with the given roles in the test client, issued to it. */\nexport function personClaims(roles: readonly string[], azp: string = TEST_CLIENT): Record<string, unknown> {\n return { sub: 'p-1', email: 'anna@example.com', resource_access: { [TEST_CLIENT]: { roles } }, azp };\n}\n"]}
@@ -0,0 +1,29 @@
1
+ import { JWTVerifyGetKey } from 'jose';
2
+ import { ICaller } from '@rt-tools/auth-contract';
3
+ /** Where the tokens of an admin come from: the realm and the client of this admin. */
4
+ export interface ITokenCheckOptions {
5
+ /** The realm issuer, for example `https://sso.example.com/realms/rt`. */
6
+ readonly issuer: string;
7
+ /** The client of this admin in the realm. A token issued to another client is refused. */
8
+ readonly clientId: string;
9
+ }
10
+ /** The token of the `Authorization` header, or `null` when the header is absent or of another form. */
11
+ export declare function bearerToken(authorization: string | null | undefined): string | null;
12
+ /**
13
+ * The token check of one admin, shared by the NestJS guard and the Connect interceptor.
14
+ *
15
+ * A token is accepted when a key of the realm signed it, the realm issued it, its term holds and
16
+ * it was issued to the client of this admin (`azp`). Every other case answers `null` and nothing
17
+ * more: the reason of a refusal is not told to the caller, so the answers do not show which tokens
18
+ * come close.
19
+ *
20
+ * The keys are fetched from the key set address of the realm and cached by `jose`; a key the
21
+ * realm rotates in is picked up without a restart.
22
+ */
23
+ export declare class KeycloakTokenVerifier {
24
+ #private;
25
+ constructor(options: ITokenCheckOptions, keys?: JWTVerifyGetKey);
26
+ /** The caller of the `Authorization` header, or `null` when the token is not accepted. */
27
+ callerOf(authorization: string | null | undefined): Promise<ICaller | null>;
28
+ }
29
+ //# sourceMappingURL=token-verifier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-verifier.d.ts","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/token-verifier.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,eAAe,EAAa,MAAM,MAAM,CAAC;AAElF,OAAO,EAAoB,OAAO,EAAmB,MAAM,yBAAyB,CAAC;AAErF,sFAAsF;AACtF,MAAM,WAAW,kBAAkB;IAC/B,yEAAyE;IACzE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,0FAA0F;IAC1F,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC7B;AAID,uGAAuG;AACvG,wBAAgB,WAAW,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAGnF;AA0BD;;;;;;;;;;GAUG;AACH,qBAAa,qBAAqB;;gBAIlB,OAAO,EAAE,kBAAkB,EAAE,IAAI,CAAC,EAAE,eAAe;IAK/D,0FAA0F;IAC7E,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;CAe3F"}
@@ -0,0 +1,67 @@
1
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
2
+ import { callerFromClaims } from '@rt-tools/auth-contract';
3
+ const BEARER = /^Bearer ([A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+)$/;
4
+ /** The token of the `Authorization` header, or `null` when the header is absent or of another form. */
5
+ export function bearerToken(authorization) {
6
+ const match = BEARER.exec(authorization?.trim() ?? '');
7
+ return match ? match[1] : null;
8
+ }
9
+ function resourceAccessOf(value) {
10
+ if (typeof value !== 'object' || value === null) {
11
+ return undefined;
12
+ }
13
+ const access = {};
14
+ for (const [clientId, entry] of Object.entries(value)) {
15
+ const roles = typeof entry === 'object' && entry !== null ? Reflect.get(entry, 'roles') : undefined;
16
+ access[clientId] = { roles: Array.isArray(roles) ? roles.filter((role) => typeof role === 'string') : [] };
17
+ }
18
+ return access;
19
+ }
20
+ /** The claims the caller is read from, taken from a verified payload by their shape. */
21
+ function claimsOf(payload) {
22
+ return {
23
+ sub: String(payload.sub),
24
+ email: typeof payload['email'] === 'string' ? payload['email'] : undefined,
25
+ email_verified: payload['email_verified'] === true,
26
+ name: typeof payload['name'] === 'string' ? payload['name'] : undefined,
27
+ preferred_username: typeof payload['preferred_username'] === 'string' ? payload['preferred_username'] : undefined,
28
+ resource_access: resourceAccessOf(payload['resource_access']),
29
+ };
30
+ }
31
+ /**
32
+ * The token check of one admin, shared by the NestJS guard and the Connect interceptor.
33
+ *
34
+ * A token is accepted when a key of the realm signed it, the realm issued it, its term holds and
35
+ * it was issued to the client of this admin (`azp`). Every other case answers `null` and nothing
36
+ * more: the reason of a refusal is not told to the caller, so the answers do not show which tokens
37
+ * come close.
38
+ *
39
+ * The keys are fetched from the key set address of the realm and cached by `jose`; a key the
40
+ * realm rotates in is picked up without a restart.
41
+ */
42
+ export class KeycloakTokenVerifier {
43
+ #options;
44
+ #keys;
45
+ constructor(options, keys) {
46
+ this.#options = options;
47
+ this.#keys = keys ?? createRemoteJWKSet(new URL(`${options.issuer}/protocol/openid-connect/certs`));
48
+ }
49
+ /** The caller of the `Authorization` header, or `null` when the token is not accepted. */
50
+ async callerOf(authorization) {
51
+ const token = bearerToken(authorization);
52
+ if (!token) {
53
+ return null;
54
+ }
55
+ try {
56
+ const { payload } = await jwtVerify(token, this.#keys, { issuer: this.#options.issuer });
57
+ if (payload['azp'] !== this.#options.clientId || typeof payload.sub !== 'string') {
58
+ return null;
59
+ }
60
+ return callerFromClaims(claimsOf(payload), this.#options.clientId);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ }
67
+ //# sourceMappingURL=token-verifier.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-verifier.js","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/token-verifier.ts"],"names":[],"mappings":"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","sourcesContent":["import { createRemoteJWKSet, JWTPayload, JWTVerifyGetKey, jwtVerify } from 'jose';\n\nimport { callerFromClaims, ICaller, IKeycloakClaims } from '@rt-tools/auth-contract';\n\n/** Where the tokens of an admin come from: the realm and the client of this admin. */\nexport interface ITokenCheckOptions {\n /** The realm issuer, for example `https://sso.example.com/realms/rt`. */\n readonly issuer: string;\n /** The client of this admin in the realm. A token issued to another client is refused. */\n readonly clientId: string;\n}\n\nconst BEARER: RegExp = /^Bearer ([A-Za-z0-9\\-_]+\\.[A-Za-z0-9\\-_]+\\.[A-Za-z0-9\\-_]+)$/;\n\n/** The token of the `Authorization` header, or `null` when the header is absent or of another form. */\nexport function bearerToken(authorization: string | null | undefined): string | null {\n const match: RegExpExecArray | null = BEARER.exec(authorization?.trim() ?? '');\n return match ? match[1] : null;\n}\n\nfunction resourceAccessOf(value: unknown): IKeycloakClaims['resource_access'] {\n if (typeof value !== 'object' || value === null) {\n return undefined;\n }\n const access: Record<string, { roles?: readonly string[] }> = {};\n for (const [clientId, entry] of Object.entries(value)) {\n const roles: unknown = typeof entry === 'object' && entry !== null ? Reflect.get(entry, 'roles') : undefined;\n access[clientId] = { roles: Array.isArray(roles) ? roles.filter((role: unknown): role is string => typeof role === 'string') : [] };\n }\n return access;\n}\n\n/** The claims the caller is read from, taken from a verified payload by their shape. */\nfunction claimsOf(payload: JWTPayload): IKeycloakClaims {\n return {\n sub: String(payload.sub),\n email: typeof payload['email'] === 'string' ? payload['email'] : undefined,\n email_verified: payload['email_verified'] === true,\n name: typeof payload['name'] === 'string' ? payload['name'] : undefined,\n preferred_username: typeof payload['preferred_username'] === 'string' ? payload['preferred_username'] : undefined,\n resource_access: resourceAccessOf(payload['resource_access']),\n };\n}\n\n/**\n * The token check of one admin, shared by the NestJS guard and the Connect interceptor.\n *\n * A token is accepted when a key of the realm signed it, the realm issued it, its term holds and\n * it was issued to the client of this admin (`azp`). Every other case answers `null` and nothing\n * more: the reason of a refusal is not told to the caller, so the answers do not show which tokens\n * come close.\n *\n * The keys are fetched from the key set address of the realm and cached by `jose`; a key the\n * realm rotates in is picked up without a restart.\n */\nexport class KeycloakTokenVerifier {\n readonly #options: ITokenCheckOptions;\n readonly #keys: JWTVerifyGetKey;\n\n constructor(options: ITokenCheckOptions, keys?: JWTVerifyGetKey) {\n this.#options = options;\n this.#keys = keys ?? createRemoteJWKSet(new URL(`${options.issuer}/protocol/openid-connect/certs`));\n }\n\n /** The caller of the `Authorization` header, or `null` when the token is not accepted. */\n public async callerOf(authorization: string | null | undefined): Promise<ICaller | null> {\n const token: string | null = bearerToken(authorization);\n if (!token) {\n return null;\n }\n try {\n const { payload } = await jwtVerify(token, this.#keys, { issuer: this.#options.issuer });\n if (payload['azp'] !== this.#options.clientId || typeof payload.sub !== 'string') {\n return null;\n }\n return callerFromClaims(claimsOf(payload), this.#options.clientId);\n } catch {\n return null;\n }\n }\n}\n"]}
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@rt-tools/auth-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Keycloak token check, access declarations and the rights catalog sync for NestJS controllers and Connect procedures",
5
+ "author": "RT Team",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/nickmdf/rt-tools.git",
10
+ "directory": "projects/auth-server"
11
+ },
12
+ "homepage": "https://github.com/nickmdf/rt-tools/tree/main/projects/auth-server",
13
+ "bugs": {
14
+ "url": "https://github.com/nickmdf/rt-tools/issues"
15
+ },
16
+ "keywords": [
17
+ "nestjs",
18
+ "connect",
19
+ "keycloak",
20
+ "oidc",
21
+ "permissions"
22
+ ],
23
+ "dependencies": {
24
+ "@rt-tools/auth-contract": "^0.1.0",
25
+ "jose": "^6.2.12",
26
+ "tslib": "^2.3.0"
27
+ },
28
+ "peerDependencies": {
29
+ "@nestjs/common": "^11.0.0",
30
+ "@nestjs/core": "^11.0.0",
31
+ "@connectrpc/connect": "^2.0.0",
32
+ "reflect-metadata": "^0.2.0",
33
+ "rxjs": "^7.8.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@connectrpc/connect": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "@connectrpc/connect": "2.2.0"
42
+ },
43
+ "sideEffects": false,
44
+ "main": "./cjs/index.js",
45
+ "types": "./esm/index.d.ts",
46
+ "module": "./esm/index.js",
47
+ "exports": {
48
+ "./package.json": "./package.json",
49
+ ".": {
50
+ "types": "./esm/index.d.ts",
51
+ "import": "./esm/index.js",
52
+ "require": "./cjs/index.js"
53
+ }
54
+ }
55
+ }