idp-admin 0.3.0 → 0.4.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 +2 -2
- package/dist/errors.d.ts +5 -1
- package/dist/errors.js +12 -3
- package/dist/idp-admin.d.ts +6 -1
- package/dist/idp-admin.js +37 -17
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/passwords.d.ts +10 -0
- package/dist/passwords.js +36 -0
- package/docs/index.md +23 -6
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ const admin = new IdpAdmin(adapter, {
|
|
|
72
72
|
`attributes` maps the names your app uses to the provider's own, and only those names compile.
|
|
73
73
|
|
|
74
74
|
```ts
|
|
75
|
-
import { UserExistsError } from "idp-admin"
|
|
75
|
+
import { UserExistsError, generateTemporaryPassword } from "idp-admin"
|
|
76
76
|
|
|
77
77
|
// create a user who must set their own password at first sign-in; send them the temporary one yourself
|
|
78
78
|
try{
|
|
@@ -80,7 +80,7 @@ try{
|
|
|
80
80
|
email: "Dana@Example.com", // stored and matched as dana@example.com
|
|
81
81
|
name: "Dana Lee",
|
|
82
82
|
attributes: { company: "Acme" },
|
|
83
|
-
temporaryPassword:
|
|
83
|
+
temporaryPassword: generateTemporaryPassword(),
|
|
84
84
|
})
|
|
85
85
|
await admin.ensureGroup("admins")
|
|
86
86
|
await admin.addToGroup(user.id, "admins")
|
package/dist/errors.d.ts
CHANGED
|
@@ -40,18 +40,22 @@ export type AccessReductionStep = "provider-sign-out" | "hook";
|
|
|
40
40
|
/**
|
|
41
41
|
* The provider change happened, but a step after it didn't, so the user may still have live sessions
|
|
42
42
|
* or provider tokens. The provider change is not undone. Calling the same method again is safe: it
|
|
43
|
-
* repeats every step.
|
|
43
|
+
* repeats every step. The exception is `deleteGroup`, which stops at the member whose step failed and
|
|
44
|
+
* leaves the group in place: call `removeFromGroup` for that member and group, then `deleteGroup` again.
|
|
44
45
|
*/
|
|
45
46
|
export declare class AccessReductionIncompleteError extends IdpAdminError {
|
|
46
47
|
readonly code = "ACCESS_REDUCTION_INCOMPLETE";
|
|
47
48
|
readonly userId: string;
|
|
48
49
|
readonly action: AccessReductionAction;
|
|
50
|
+
readonly group: string | undefined;
|
|
49
51
|
readonly failedStep: AccessReductionStep;
|
|
50
52
|
constructor(details: {
|
|
51
53
|
userId: string;
|
|
52
54
|
action: AccessReductionAction;
|
|
55
|
+
group?: string;
|
|
53
56
|
failedStep: AccessReductionStep;
|
|
54
57
|
cause: unknown;
|
|
58
|
+
deletingGroup?: boolean;
|
|
55
59
|
});
|
|
56
60
|
}
|
|
57
61
|
/** Every idp-admin error, as a union, so `switch(error.code)` narrows to the matching class. */
|
package/dist/errors.js
CHANGED
|
@@ -71,19 +71,28 @@ const STEP_TEXT = {
|
|
|
71
71
|
/**
|
|
72
72
|
* The provider change happened, but a step after it didn't, so the user may still have live sessions
|
|
73
73
|
* or provider tokens. The provider change is not undone. Calling the same method again is safe: it
|
|
74
|
-
* repeats every step.
|
|
74
|
+
* repeats every step. The exception is `deleteGroup`, which stops at the member whose step failed and
|
|
75
|
+
* leaves the group in place: call `removeFromGroup` for that member and group, then `deleteGroup` again.
|
|
75
76
|
*/
|
|
76
77
|
class AccessReductionIncompleteError extends IdpAdminError {
|
|
77
78
|
code = "ACCESS_REDUCTION_INCOMPLETE";
|
|
78
79
|
userId;
|
|
79
80
|
action;
|
|
81
|
+
// the group the user was removed from, for `removed-from-group`
|
|
82
|
+
group;
|
|
80
83
|
failedStep;
|
|
81
84
|
constructor(details) {
|
|
82
85
|
const reason = details.cause instanceof Error ? details.cause.message : String(details.cause);
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
const action = details.action === "removed-from-group" && details.group !== undefined
|
|
87
|
+
? `removed from group ${details.group}`
|
|
88
|
+
: ACTION_TEXT[details.action];
|
|
89
|
+
const recovery = details.deletingGroup
|
|
90
|
+
? "The group wasn't deleted. Call removeFromGroup for this user and group, then deleteGroup again."
|
|
91
|
+
: "Calling the same method again is safe and retries it.";
|
|
92
|
+
super(`User ${details.userId} was ${action} at the provider, but ${STEP_TEXT[details.failedStep]}: ${reason}. ${recovery}`, { cause: details.cause });
|
|
85
93
|
this.userId = details.userId;
|
|
86
94
|
this.action = details.action;
|
|
95
|
+
this.group = details.group;
|
|
87
96
|
this.failedStep = details.failedStep;
|
|
88
97
|
}
|
|
89
98
|
}
|
package/dist/idp-admin.d.ts
CHANGED
|
@@ -78,6 +78,11 @@ export declare class IdpAdmin<TAttr extends string = never> {
|
|
|
78
78
|
listGroupMembers(group: string): AsyncGenerator<IdpUser<TAttr>>;
|
|
79
79
|
/** Creates the group, or does nothing if it exists. */
|
|
80
80
|
ensureGroup(group: string): Promise<void>;
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Removes every member from the group the way `removeFromGroup` does (signed out at the provider, then
|
|
83
|
+
* `onAccessReduced`), then deletes the group. If a member's step fails it stops there and leaves the
|
|
84
|
+
* group in place: call `removeFromGroup` for that member, then `deleteGroup` again.
|
|
85
|
+
* @throws {GroupNotFoundError} @throws {AccessReductionIncompleteError} a member is removed, but a later step failed
|
|
86
|
+
*/
|
|
82
87
|
deleteGroup(group: string): Promise<void>;
|
|
83
88
|
}
|
package/dist/idp-admin.js
CHANGED
|
@@ -73,8 +73,7 @@ class IdpAdmin {
|
|
|
73
73
|
*/
|
|
74
74
|
async disableUser(userId) {
|
|
75
75
|
await this.adapter.setEnabled(userId, false);
|
|
76
|
-
await this.#
|
|
77
|
-
await this.#accessReduced({ userId, action: "disabled" });
|
|
76
|
+
await this.#reduced({ userId, action: "disabled" }, { signOut: true });
|
|
78
77
|
}
|
|
79
78
|
/**
|
|
80
79
|
* Deletes the user, then calls `onAccessReduced`, also when the user was already gone. Deleting ends
|
|
@@ -92,7 +91,7 @@ class IdpAdmin {
|
|
|
92
91
|
throw error;
|
|
93
92
|
deleted = false;
|
|
94
93
|
}
|
|
95
|
-
await this.#
|
|
94
|
+
await this.#reduced({ userId, action: "deleted" }, { signOut: false });
|
|
96
95
|
return deleted;
|
|
97
96
|
}
|
|
98
97
|
// ---- groups
|
|
@@ -115,8 +114,7 @@ class IdpAdmin {
|
|
|
115
114
|
// nobody is in a group that doesn't exist, but the user must still exist
|
|
116
115
|
await this.adapter.getUser(userId);
|
|
117
116
|
}
|
|
118
|
-
await this.#
|
|
119
|
-
await this.#accessReduced({ userId, action: "removed-from-group", group });
|
|
117
|
+
await this.#reduced({ userId, action: "removed-from-group", group }, { signOut: true });
|
|
120
118
|
}
|
|
121
119
|
/** The names of every group the user is in. @throws {UserNotFoundError} */
|
|
122
120
|
listUserGroups(userId) {
|
|
@@ -139,25 +137,47 @@ class IdpAdmin {
|
|
|
139
137
|
throw error;
|
|
140
138
|
}
|
|
141
139
|
}
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Removes every member from the group the way `removeFromGroup` does (signed out at the provider, then
|
|
142
|
+
* `onAccessReduced`), then deletes the group. If a member's step fails it stops there and leaves the
|
|
143
|
+
* group in place: call `removeFromGroup` for that member, then `deleteGroup` again.
|
|
144
|
+
* @throws {GroupNotFoundError} @throws {AccessReductionIncompleteError} a member is removed, but a later step failed
|
|
145
|
+
*/
|
|
146
|
+
async deleteGroup(group) {
|
|
147
|
+
// collected first, because removing members while paging through them would skip some
|
|
148
|
+
const members = [];
|
|
149
|
+
for await (const member of this.listGroupMembers(group))
|
|
150
|
+
members.push(member.id);
|
|
151
|
+
for (const userId of members) {
|
|
152
|
+
await this.adapter.removeFromGroup(userId, group);
|
|
153
|
+
await this.#reduced({ userId, action: "removed-from-group", group }, { signOut: true, deletingGroup: true });
|
|
154
|
+
}
|
|
155
|
+
await this.adapter.deleteGroup(group);
|
|
145
156
|
}
|
|
146
157
|
// ---- access reduction
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
158
|
+
// the steps after a provider change that reduced someone's access: provider sign-out, then the hook
|
|
159
|
+
async #reduced(event, options) {
|
|
160
|
+
const incomplete = (failedStep, cause) => new errors_1.AccessReductionIncompleteError({
|
|
161
|
+
userId: event.userId,
|
|
162
|
+
action: event.action,
|
|
163
|
+
group: event.action === "removed-from-group" ? event.group : undefined,
|
|
164
|
+
failedStep,
|
|
165
|
+
cause,
|
|
166
|
+
deletingGroup: options.deletingGroup,
|
|
167
|
+
});
|
|
168
|
+
if (options.signOut) {
|
|
169
|
+
try {
|
|
170
|
+
await this.adapter.signOutEverywhere(event.userId);
|
|
171
|
+
}
|
|
172
|
+
catch (cause) {
|
|
173
|
+
throw incomplete("provider-sign-out", cause);
|
|
174
|
+
}
|
|
153
175
|
}
|
|
154
|
-
}
|
|
155
|
-
async #accessReduced(event) {
|
|
156
176
|
try {
|
|
157
177
|
await this.#onAccessReduced?.(event);
|
|
158
178
|
}
|
|
159
179
|
catch (cause) {
|
|
160
|
-
throw
|
|
180
|
+
throw incomplete("hook", cause);
|
|
161
181
|
}
|
|
162
182
|
}
|
|
163
183
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type { AdapterCreateUserInput, AdapterInviteUserInput, IdpAdapter } from "./adapter";
|
|
2
2
|
export { AccessReductionIncompleteError, GroupExistsError, GroupNotFoundError, IdpAdminError, NotSupportedError, UserExistsError, UserNotFoundError, type AccessReductionAction, type AccessReductionStep, type AnyIdpAdminError, } from "./errors";
|
|
3
3
|
export { IdpAdmin, type AccessReducedEvent, type IdpAdminOptions, type IdpAdminSupports } from "./idp-admin";
|
|
4
|
+
export { generateTemporaryPassword, type TemporaryPasswordOptions } from "./passwords";
|
|
4
5
|
export type { IdpUser, Page, UserAttributes, UserStatus } from "./types";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.IdpAdmin = exports.UserNotFoundError = exports.UserExistsError = exports.NotSupportedError = exports.IdpAdminError = exports.GroupNotFoundError = exports.GroupExistsError = exports.AccessReductionIncompleteError = void 0;
|
|
3
|
+
exports.generateTemporaryPassword = exports.IdpAdmin = exports.UserNotFoundError = exports.UserExistsError = exports.NotSupportedError = exports.IdpAdminError = exports.GroupNotFoundError = exports.GroupExistsError = exports.AccessReductionIncompleteError = void 0;
|
|
4
4
|
var errors_1 = require("./errors");
|
|
5
5
|
Object.defineProperty(exports, "AccessReductionIncompleteError", { enumerable: true, get: function () { return errors_1.AccessReductionIncompleteError; } });
|
|
6
6
|
Object.defineProperty(exports, "GroupExistsError", { enumerable: true, get: function () { return errors_1.GroupExistsError; } });
|
|
@@ -11,3 +11,5 @@ Object.defineProperty(exports, "UserExistsError", { enumerable: true, get: funct
|
|
|
11
11
|
Object.defineProperty(exports, "UserNotFoundError", { enumerable: true, get: function () { return errors_1.UserNotFoundError; } });
|
|
12
12
|
var idp_admin_1 = require("./idp-admin");
|
|
13
13
|
Object.defineProperty(exports, "IdpAdmin", { enumerable: true, get: function () { return idp_admin_1.IdpAdmin; } });
|
|
14
|
+
var passwords_1 = require("./passwords");
|
|
15
|
+
Object.defineProperty(exports, "generateTemporaryPassword", { enumerable: true, get: function () { return passwords_1.generateTemporaryPassword; } });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type TemporaryPasswordOptions = {
|
|
2
|
+
length?: number;
|
|
3
|
+
};
|
|
4
|
+
/**
|
|
5
|
+
* A random temporary password for `createUser` or `setPassword`, from Node's cryptographic random
|
|
6
|
+
* source. It always has at least one uppercase letter, lowercase letter, digit and symbol, so default
|
|
7
|
+
* password policies accept it, and is 16 characters unless `length` says otherwise. A provider policy
|
|
8
|
+
* stricter than that (a longer minimum, say) needs a matching `length`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function generateTemporaryPassword(options?: TemporaryPasswordOptions): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateTemporaryPassword = generateTemporaryPassword;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
6
|
+
const LOWER = "abcdefghijklmnopqrstuvwxyz";
|
|
7
|
+
const DIGITS = "0123456789";
|
|
8
|
+
// a subset every provider accepts, and that needs no escaping in an email or a URL
|
|
9
|
+
const SYMBOLS = "!@#$%^&*";
|
|
10
|
+
const ALL = UPPER + LOWER + DIGITS + SYMBOLS;
|
|
11
|
+
/**
|
|
12
|
+
* A random temporary password for `createUser` or `setPassword`, from Node's cryptographic random
|
|
13
|
+
* source. It always has at least one uppercase letter, lowercase letter, digit and symbol, so default
|
|
14
|
+
* password policies accept it, and is 16 characters unless `length` says otherwise. A provider policy
|
|
15
|
+
* stricter than that (a longer minimum, say) needs a matching `length`.
|
|
16
|
+
*/
|
|
17
|
+
function generateTemporaryPassword(options = {}) {
|
|
18
|
+
const length = options.length ?? 16;
|
|
19
|
+
if (!Number.isInteger(length) || length < 8 || length > 256) {
|
|
20
|
+
throw new RangeError(`A temporary password's length must be a whole number from 8 to 256, not ${length}`);
|
|
21
|
+
}
|
|
22
|
+
const characters = [UPPER, LOWER, DIGITS, SYMBOLS].map(pick);
|
|
23
|
+
while (characters.length < length)
|
|
24
|
+
characters.push(pick(ALL));
|
|
25
|
+
// shuffle, so the guaranteed characters aren't always first
|
|
26
|
+
for (let i = characters.length - 1; i > 0; i--) {
|
|
27
|
+
const j = (0, node_crypto_1.randomInt)(i + 1);
|
|
28
|
+
const swapped = characters[i];
|
|
29
|
+
characters[i] = characters[j];
|
|
30
|
+
characters[j] = swapped;
|
|
31
|
+
}
|
|
32
|
+
return characters.join("");
|
|
33
|
+
}
|
|
34
|
+
function pick(from) {
|
|
35
|
+
return from[(0, node_crypto_1.randomInt)(from.length)];
|
|
36
|
+
}
|
package/docs/index.md
CHANGED
|
@@ -39,12 +39,12 @@ type AccessReducedEvent =
|
|
|
39
39
|
### Shared rules
|
|
40
40
|
|
|
41
41
|
- **Emails** are trimmed and lowercased before they reach the adapter, in `createUser`, `createUserWithInvite` and `findUserByEmail`. `Dana@Example.com` and `dana@example.com` are the same user.
|
|
42
|
-
- **Taking access away** (`deleteUser`, `disableUser`, `removeFromGroup`) runs these steps in order:
|
|
42
|
+
- **Taking access away** (`deleteUser`, `disableUser`, `removeFromGroup`, and `deleteGroup` for each member) runs these steps in order:
|
|
43
43
|
1. the change at the provider
|
|
44
|
-
2. provider sign-out (`signOutEverywhere`), for
|
|
44
|
+
2. provider sign-out (`signOutEverywhere`), for everything but `deleteUser`. Deleting already ends the user's tokens at the provider, so `deleteUser` skips it
|
|
45
45
|
3. `onAccessReduced`
|
|
46
46
|
|
|
47
|
-
If step 2 or 3 fails, the method throws `AccessReductionIncompleteError` and the provider change stands.
|
|
47
|
+
If step 2 or 3 fails, the method throws `AccessReductionIncompleteError` and the provider change stands. `deleteUser`, `disableUser` and `removeFromGroup` are safe to repeat and run every step each time, so calling the same method again is the recovery. `deleteGroup` stops at the member whose step failed and leaves the group in place; the member is already out of it, so calling `deleteGroup` again wouldn't reach them. Call `removeFromGroup(error.userId, group)`, then `deleteGroup` again. The error's message says so.
|
|
48
48
|
- **Provider sign-out revokes refresh tokens.** Access and ID tokens already issued stay valid until they expire (about an hour by default on Cognito). Ending the app's sessions in `onAccessReduced` is what takes effect immediately.
|
|
49
49
|
- **Lists** are async iterators over the adapter's pages. The next page is fetched only when the loop reaches it, and `break` stops fetching.
|
|
50
50
|
- **Optional features:** a method the adapter lacks throws `NotSupportedError`. `supports` tells you beforehand.
|
|
@@ -70,7 +70,7 @@ type AccessReducedEvent =
|
|
|
70
70
|
| `listUserGroups(userId)` | `AsyncGenerator<string>` | The names of the groups a user is in. |
|
|
71
71
|
| `listGroupMembers(group)` | `AsyncGenerator<IdpUser>` | Throws `GroupNotFoundError`. |
|
|
72
72
|
| `ensureGroup(group)` | `void` | Creates the group, or does nothing if it exists, including when another caller creates it at the same moment. |
|
|
73
|
-
| `deleteGroup(group)` | `void` | Removes every member
|
|
73
|
+
| `deleteGroup(group)` | `void` | Removes every member as `removeFromGroup` does (provider sign-out, then `onAccessReduced` for each), then deletes the group. Throws `GroupNotFoundError`. See **Taking access away** for recovering from a failure part way. |
|
|
74
74
|
|
|
75
75
|
Unless a row says otherwise, a method addressing a missing user throws `UserNotFoundError`.
|
|
76
76
|
|
|
@@ -79,6 +79,22 @@ Unless a row says otherwise, a method addressing a missing user throws `UserNotF
|
|
|
79
79
|
| `adapter` | The adapter passed in, for its `native` client and anything the shared methods don't cover. Calls made on it directly skip the shared rules. |
|
|
80
80
|
| `supports` | `{ createUserWithInvite: boolean; resendInvite: boolean }`: which optional features the adapter has. |
|
|
81
81
|
|
|
82
|
+
## `generateTemporaryPassword(options?)`
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { generateTemporaryPassword } from "idp-admin"
|
|
86
|
+
|
|
87
|
+
await admin.createUser({ email, temporaryPassword: generateTemporaryPassword() })
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A random password for `createUser` or a temporary `setPassword`, from Node's cryptographic random source (`crypto.randomInt`).
|
|
91
|
+
|
|
92
|
+
| Option | Default | Meaning |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `length` | `16` | A whole number from 8 to 256. Anything else throws a `RangeError`. |
|
|
95
|
+
|
|
96
|
+
It always contains at least one uppercase letter, lowercase letter, digit and symbol, in random positions, so the default password policies of Cognito and Keycloak accept it. Symbols are from `!@#$%^&*`, which every provider accepts and which need no escaping in an email or a URL. If your pool or realm requires more than 16 characters, pass a matching `length`.
|
|
97
|
+
|
|
82
98
|
## Types
|
|
83
99
|
|
|
84
100
|
### `IdpUser<TAttr>`
|
|
@@ -146,13 +162,14 @@ Every error idp-admin throws on purpose extends `IdpAdminError`, so `instanceof
|
|
|
146
162
|
| `GroupNotFoundError` | `GROUP_NOT_FOUND` | `group` | The group doesn't exist. |
|
|
147
163
|
| `GroupExistsError` | `GROUP_EXISTS` | `group` | Creating a group that exists. |
|
|
148
164
|
| `NotSupportedError` | `NOT_SUPPORTED` | `feature` | The adapter can't do what was asked; `feature` names the method or option. |
|
|
149
|
-
| `AccessReductionIncompleteError` | `ACCESS_REDUCTION_INCOMPLETE` | `userId`, `action`, `failedStep`, `cause` | A user was deleted, disabled or removed from a group at the provider, but a later step failed. |
|
|
165
|
+
| `AccessReductionIncompleteError` | `ACCESS_REDUCTION_INCOMPLETE` | `userId`, `action`, `group`, `failedStep`, `cause` | A user was deleted, disabled or removed from a group at the provider, but a later step failed. |
|
|
150
166
|
|
|
151
167
|
`AccessReductionIncompleteError`:
|
|
152
168
|
- `action` is `"deleted"`, `"disabled"` or `"removed-from-group"`: what already happened at the provider. It is not undone.
|
|
169
|
+
- `group` is the group they were removed from, for `"removed-from-group"`; otherwise `undefined`.
|
|
153
170
|
- `failedStep` is `"provider-sign-out"` or `"hook"`: the step after it that failed.
|
|
154
171
|
- `cause` is the original error.
|
|
155
|
-
- Calling the same method again is safe and repeats every step.
|
|
172
|
+
- Calling the same method again is safe and repeats every step, except after `deleteGroup`: call `removeFromGroup(userId, group)`, then `deleteGroup` again.
|
|
156
173
|
|
|
157
174
|
## `IdpAdapter<TAttr>`
|
|
158
175
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idp-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "One user-management API for any identity provider.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"identity-provider",
|
|
@@ -76,5 +76,10 @@
|
|
|
76
76
|
"jest": "^30.5.2",
|
|
77
77
|
"tsx": "^4.23.15",
|
|
78
78
|
"typescript": "5.9.3"
|
|
79
|
+
},
|
|
80
|
+
"overrides": {
|
|
81
|
+
"@istanbuljs/load-nyc-config": {
|
|
82
|
+
"js-yaml": "^4.1.0"
|
|
83
|
+
}
|
|
79
84
|
}
|
|
80
85
|
}
|