@seamless-auth/types 0.2.0 → 0.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/CHANGELOG.md +19 -0
- package/README.md +25 -1
- package/dist/schemas/role/matching.d.ts +20 -0
- package/dist/schemas/role/matching.d.ts.map +1 -0
- package/dist/schemas/role/matching.js +59 -0
- package/dist/schemas/role/matching.js.map +1 -0
- package/dist/schemas/role/schema.d.ts +1 -14
- package/dist/schemas/role/schema.d.ts.map +1 -1
- package/dist/schemas/role/schema.js +2 -53
- package/dist/schemas/role/schema.js.map +1 -1
- package/package.json +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @seamless-auth/types
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f180c99: Add a Zod-free entry point for role matching.
|
|
8
|
+
|
|
9
|
+
`ROLE_NAME_PATTERN`, `roleGrantsAccess`, and `hasScopedRole` moved to
|
|
10
|
+
`src/schemas/role/matching.ts`, which imports nothing at runtime. Two subpath
|
|
11
|
+
exports expose it: `@seamless-auth/types/role/matching` for the matchers alone,
|
|
12
|
+
and `@seamless-auth/types/role` for those plus `RoleNameSchema`. Consumers on
|
|
13
|
+
the authorization path can now match roles without loading Zod or the schema
|
|
14
|
+
barrel.
|
|
15
|
+
|
|
16
|
+
The package also declares `"sideEffects": false` so bundlers can tree-shake the
|
|
17
|
+
barrel.
|
|
18
|
+
|
|
19
|
+
This is additive. The package root still exports everything it did before, and
|
|
20
|
+
the matching behavior is unchanged.
|
|
21
|
+
|
|
3
22
|
## 0.2.0
|
|
4
23
|
|
|
5
24
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -45,6 +45,23 @@ const user = UserSchema.parse(data);
|
|
|
45
45
|
User role schemas accept plain roles such as `admin` and colon-separated scoped roles such as
|
|
46
46
|
`admin:read` and `admin:write`. Whitespace, underscores, slashes, and backslashes are rejected.
|
|
47
47
|
|
|
48
|
+
### Match roles without Zod
|
|
49
|
+
|
|
50
|
+
`roleGrantsAccess` and `hasScopedRole` are plain string logic, but reaching them through the
|
|
51
|
+
package root loads Zod and every schema in the barrel. Code on the authorization hot path can
|
|
52
|
+
import them from `@seamless-auth/types/role/matching` instead, which has no runtime dependencies:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { hasScopedRole } from '@seamless-auth/types/role/matching';
|
|
56
|
+
|
|
57
|
+
if (!hasScopedRole(user.roles, 'billing:invoices:read')) {
|
|
58
|
+
throw new Error('forbidden');
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`@seamless-auth/types/role` adds `RoleNameSchema` on top of the same matchers, and the package
|
|
63
|
+
root still exports all of them, so existing imports keep working.
|
|
64
|
+
|
|
48
65
|
### Infer types
|
|
49
66
|
|
|
50
67
|
```ts
|
|
@@ -60,7 +77,7 @@ function handleUser(user: User) {
|
|
|
60
77
|
## Modules
|
|
61
78
|
|
|
62
79
|
Every module is re-exported from the package root, so import from
|
|
63
|
-
`@seamless-auth/types`
|
|
80
|
+
`@seamless-auth/types` unless one of the subpath exports below fits better.
|
|
64
81
|
|
|
65
82
|
| Module | Covers |
|
|
66
83
|
| -------------- | ------------------------------------------------------------------- |
|
|
@@ -82,6 +99,13 @@ Every module is re-exported from the package root, so import from
|
|
|
82
99
|
| `metrics` | Dashboard metrics, timeseries, and security anomalies |
|
|
83
100
|
| `admin` | Admin-only user detail, anomalies, and device replacement recovery |
|
|
84
101
|
|
|
102
|
+
### Subpath exports
|
|
103
|
+
|
|
104
|
+
| Subpath | Covers |
|
|
105
|
+
| ------------------------------------ | -------------------------------------- |
|
|
106
|
+
| `@seamless-auth/types/role` | Role name schema plus the matchers |
|
|
107
|
+
| `@seamless-auth/types/role/matching` | The matchers alone, with no Zod import |
|
|
108
|
+
|
|
85
109
|
---
|
|
86
110
|
|
|
87
111
|
## Zod as the Source of Truth
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role matching logic, deliberately free of any Zod import. Consumers on the
|
|
3
|
+
* authorization hot path can reach this through the `./role/matching` subpath
|
|
4
|
+
* export without pulling Zod into their dependency tree.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Roles are colon-scoped (`billing:invoices:read`). Underscores, slashes, and
|
|
8
|
+
* whitespace are excluded so a role name is always safe to embed in a JWT claim,
|
|
9
|
+
* a URL path segment, and a config file without escaping.
|
|
10
|
+
*/
|
|
11
|
+
export declare const ROLE_NAME_PATTERN: RegExp;
|
|
12
|
+
/**
|
|
13
|
+
* Whether a granted role satisfies a required role. A `:*` suffix grants every
|
|
14
|
+
* role under that prefix, an unscoped role grants every role beneath it, and a
|
|
15
|
+
* `:write` role implies the matching `:read` role.
|
|
16
|
+
*/
|
|
17
|
+
export declare function roleGrantsAccess(grantedRole: string, requiredRole: string): boolean;
|
|
18
|
+
/** Whether any granted role satisfies at least one of the required roles. */
|
|
19
|
+
export declare function hasScopedRole(grantedRoles: unknown, requiredRoles: string | string[]): boolean;
|
|
20
|
+
//# sourceMappingURL=matching.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"matching.d.ts","sourceRoot":"","sources":["../../../src/schemas/role/matching.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,QAAiE,CAAC;AAUhG;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAmCnF;AAED,6EAA6E;AAC7E,wBAAgB,aAAa,CAAC,YAAY,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,OAAO,CAW9F"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role matching logic, deliberately free of any Zod import. Consumers on the
|
|
3
|
+
* authorization hot path can reach this through the `./role/matching` subpath
|
|
4
|
+
* export without pulling Zod into their dependency tree.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Roles are colon-scoped (`billing:invoices:read`). Underscores, slashes, and
|
|
8
|
+
* whitespace are excluded so a role name is always safe to embed in a JWT claim,
|
|
9
|
+
* a URL path segment, and a config file without escaping.
|
|
10
|
+
*/
|
|
11
|
+
export const ROLE_NAME_PATTERN = /^(?!.*[_/\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$/;
|
|
12
|
+
function normalizeRole(value) {
|
|
13
|
+
return value.trim();
|
|
14
|
+
}
|
|
15
|
+
function samePrefix(left, right) {
|
|
16
|
+
return left.length === right.length && left.every((part, index) => part === right[index]);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether a granted role satisfies a required role. A `:*` suffix grants every
|
|
20
|
+
* role under that prefix, an unscoped role grants every role beneath it, and a
|
|
21
|
+
* `:write` role implies the matching `:read` role.
|
|
22
|
+
*/
|
|
23
|
+
export function roleGrantsAccess(grantedRole, requiredRole) {
|
|
24
|
+
const granted = normalizeRole(grantedRole);
|
|
25
|
+
const required = normalizeRole(requiredRole);
|
|
26
|
+
if (!granted || !required) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (granted === required) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (granted.endsWith(':*')) {
|
|
33
|
+
const prefix = granted.slice(0, -2);
|
|
34
|
+
return required === prefix || required.startsWith(`${prefix}:`);
|
|
35
|
+
}
|
|
36
|
+
if (!required.includes(':')) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (!granted.includes(':')) {
|
|
40
|
+
return required.startsWith(`${granted}:`);
|
|
41
|
+
}
|
|
42
|
+
const grantedParts = granted.split(':');
|
|
43
|
+
const requiredParts = required.split(':');
|
|
44
|
+
const grantedAction = grantedParts[grantedParts.length - 1];
|
|
45
|
+
const requiredAction = requiredParts[requiredParts.length - 1];
|
|
46
|
+
return (grantedAction === 'write' &&
|
|
47
|
+
requiredAction === 'read' &&
|
|
48
|
+
samePrefix(grantedParts.slice(0, -1), requiredParts.slice(0, -1)));
|
|
49
|
+
}
|
|
50
|
+
/** Whether any granted role satisfies at least one of the required roles. */
|
|
51
|
+
export function hasScopedRole(grantedRoles, requiredRoles) {
|
|
52
|
+
if (!Array.isArray(grantedRoles)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const required = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles];
|
|
56
|
+
const granted = grantedRoles.filter((role) => typeof role === 'string');
|
|
57
|
+
return required.some((requiredRole) => granted.some((grantedRole) => roleGrantsAccess(grantedRole, requiredRole)));
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=matching.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"matching.js","sourceRoot":"","sources":["../../../src/schemas/role/matching.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,8DAA8D,CAAC;AAEhG,SAAS,aAAa,CAAC,KAAa;IAClC,OAAO,KAAK,CAAC,IAAI,EAAE,CAAC;AACtB,CAAC;AAED,SAAS,UAAU,CAAC,IAAc,EAAE,KAAe;IACjD,OAAO,IAAI,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,KAAK,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;AAC5F,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,WAAmB,EAAE,YAAoB;IACxE,MAAM,OAAO,GAAG,aAAa,CAAC,WAAW,CAAC,CAAC;IAC3C,MAAM,QAAQ,GAAG,aAAa,CAAC,YAAY,CAAC,CAAC;IAE7C,IAAI,CAAC,OAAO,IAAI,CAAC,QAAQ,EAAE,CAAC;QAC1B,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,OAAO,KAAK,QAAQ,EAAE,CAAC;QACzB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3B,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QACpC,OAAO,QAAQ,KAAK,MAAM,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,MAAM,GAAG,CAAC,CAAC;IAClE,CAAC;IAED,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QAC5B,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QAC3B,OAAO,QAAQ,CAAC,UAAU,CAAC,GAAG,OAAO,GAAG,CAAC,CAAC;IAC5C,CAAC;IAED,MAAM,YAAY,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACxC,MAAM,aAAa,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC1C,MAAM,aAAa,GAAG,YAAY,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC5D,MAAM,cAAc,GAAG,aAAa,CAAC,aAAa,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAE/D,OAAO,CACL,aAAa,KAAK,OAAO;QACzB,cAAc,KAAK,MAAM;QACzB,UAAU,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,aAAa,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAClE,CAAC;AACJ,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,aAAa,CAAC,YAAqB,EAAE,aAAgC;IACnF,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,CAAC;QACjC,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,QAAQ,GAAG,KAAK,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC;IAChF,MAAM,OAAO,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC,IAAI,EAAkB,EAAE,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC;IAExF,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,YAAY,EAAE,EAAE,CACpC,OAAO,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC,gBAAgB,CAAC,WAAW,EAAE,YAAY,CAAC,CAAC,CAC3E,CAAC;AACJ,CAAC"}
|
|
@@ -1,20 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
|
|
3
|
-
* Roles are colon-scoped (`billing:invoices:read`). Underscores, slashes, and
|
|
4
|
-
* whitespace are excluded so a role name is always safe to embed in a JWT claim,
|
|
5
|
-
* a URL path segment, and a config file without escaping.
|
|
6
|
-
*/
|
|
7
|
-
export declare const ROLE_NAME_PATTERN: RegExp;
|
|
2
|
+
export * from './matching.js';
|
|
8
3
|
export declare const RoleNameSchema: z.ZodString;
|
|
9
4
|
export type RoleName = z.infer<typeof RoleNameSchema>;
|
|
10
5
|
/** @deprecated Use {@link RoleNameSchema}. */
|
|
11
6
|
export declare const RoleSchema: z.ZodString;
|
|
12
|
-
/**
|
|
13
|
-
* Whether a granted role satisfies a required role. A `:*` suffix grants every
|
|
14
|
-
* role under that prefix, an unscoped role grants every role beneath it, and a
|
|
15
|
-
* `:write` role implies the matching `:read` role.
|
|
16
|
-
*/
|
|
17
|
-
export declare function roleGrantsAccess(grantedRole: string, requiredRole: string): boolean;
|
|
18
|
-
/** Whether any granted role satisfies at least one of the required roles. */
|
|
19
|
-
export declare function hasScopedRole(grantedRoles: unknown, requiredRoles: string | string[]): boolean;
|
|
20
7
|
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../src/schemas/role/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../src/schemas/role/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,cAAc,eAAe,CAAC;AAE9B,eAAO,MAAM,cAAc,aAA6C,CAAC;AAEzE,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAEtD,8CAA8C;AAC9C,eAAO,MAAM,UAAU,aAAiB,CAAC"}
|
|
@@ -1,58 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* whitespace are excluded so a role name is always safe to embed in a JWT claim,
|
|
5
|
-
* a URL path segment, and a config file without escaping.
|
|
6
|
-
*/
|
|
7
|
-
export const ROLE_NAME_PATTERN = /^(?!.*[_/\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$/;
|
|
2
|
+
import { ROLE_NAME_PATTERN } from './matching.js';
|
|
3
|
+
export * from './matching.js';
|
|
8
4
|
export const RoleNameSchema = z.string().trim().regex(ROLE_NAME_PATTERN);
|
|
9
5
|
/** @deprecated Use {@link RoleNameSchema}. */
|
|
10
6
|
export const RoleSchema = RoleNameSchema;
|
|
11
|
-
function normalizeRole(value) {
|
|
12
|
-
return value.trim();
|
|
13
|
-
}
|
|
14
|
-
function samePrefix(left, right) {
|
|
15
|
-
return left.length === right.length && left.every((part, index) => part === right[index]);
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Whether a granted role satisfies a required role. A `:*` suffix grants every
|
|
19
|
-
* role under that prefix, an unscoped role grants every role beneath it, and a
|
|
20
|
-
* `:write` role implies the matching `:read` role.
|
|
21
|
-
*/
|
|
22
|
-
export function roleGrantsAccess(grantedRole, requiredRole) {
|
|
23
|
-
const granted = normalizeRole(grantedRole);
|
|
24
|
-
const required = normalizeRole(requiredRole);
|
|
25
|
-
if (!granted || !required) {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
if (granted === required) {
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
if (granted.endsWith(':*')) {
|
|
32
|
-
const prefix = granted.slice(0, -2);
|
|
33
|
-
return required === prefix || required.startsWith(`${prefix}:`);
|
|
34
|
-
}
|
|
35
|
-
if (!required.includes(':')) {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
if (!granted.includes(':')) {
|
|
39
|
-
return required.startsWith(`${granted}:`);
|
|
40
|
-
}
|
|
41
|
-
const grantedParts = granted.split(':');
|
|
42
|
-
const requiredParts = required.split(':');
|
|
43
|
-
const grantedAction = grantedParts[grantedParts.length - 1];
|
|
44
|
-
const requiredAction = requiredParts[requiredParts.length - 1];
|
|
45
|
-
return (grantedAction === 'write' &&
|
|
46
|
-
requiredAction === 'read' &&
|
|
47
|
-
samePrefix(grantedParts.slice(0, -1), requiredParts.slice(0, -1)));
|
|
48
|
-
}
|
|
49
|
-
/** Whether any granted role satisfies at least one of the required roles. */
|
|
50
|
-
export function hasScopedRole(grantedRoles, requiredRoles) {
|
|
51
|
-
if (!Array.isArray(grantedRoles)) {
|
|
52
|
-
return false;
|
|
53
|
-
}
|
|
54
|
-
const required = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles];
|
|
55
|
-
const granted = grantedRoles.filter((role) => typeof role === 'string');
|
|
56
|
-
return required.some((requiredRole) => granted.some((grantedRole) => roleGrantsAccess(grantedRole, requiredRole)));
|
|
57
|
-
}
|
|
58
7
|
//# sourceMappingURL=schema.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../src/schemas/role/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../src/schemas/role/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAElD,cAAc,eAAe,CAAC;AAE9B,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,iBAAiB,CAAC,CAAC;AAIzE,8CAA8C;AAC9C,MAAM,CAAC,MAAM,UAAU,GAAG,cAAc,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seamless-auth/types",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shared TypeScript types and Zod schemas for SeamlessAuth.",
|
|
5
5
|
"author": "Fells Code, LLC",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -11,8 +11,17 @@
|
|
|
11
11
|
".": {
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"import": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./role": {
|
|
16
|
+
"types": "./dist/schemas/role/schema.d.ts",
|
|
17
|
+
"import": "./dist/schemas/role/schema.js"
|
|
18
|
+
},
|
|
19
|
+
"./role/matching": {
|
|
20
|
+
"types": "./dist/schemas/role/matching.d.ts",
|
|
21
|
+
"import": "./dist/schemas/role/matching.js"
|
|
14
22
|
}
|
|
15
23
|
},
|
|
24
|
+
"sideEffects": false,
|
|
16
25
|
"files": [
|
|
17
26
|
"dist",
|
|
18
27
|
"README.md",
|