@kokuin/token 0.1.0 → 0.2.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/lib/did.js +24 -2
- package/lib/identity.js +5 -4
- package/lib/jwe.js +2 -1
- package/lib/peer4.d.ts +6 -1
- package/lib/peer4.js +21 -3
- package/lib/token.d.ts +26 -2
- package/lib/token.js +48 -10
- package/package.json +8 -5
package/lib/did.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { base58 } from '@scure/base';
|
|
2
2
|
import { decodeMultibase } from './multibase.js';
|
|
3
|
-
import { decodePeer4, encodePeer4, getPeer4ShortForm, isPeer4 } from './peer4.js';
|
|
3
|
+
import { assertDocWithinMaxSize, decodePeer4, encodePeer4, getPeer4ShortForm, isPeer4 } from './peer4.js';
|
|
4
4
|
/** @internal */ export const CODECS = {
|
|
5
5
|
ES256: new Uint8Array([
|
|
6
6
|
128,
|
|
@@ -16,6 +16,9 @@ const EXPECTED_KEY_SIZES = {
|
|
|
16
16
|
ES256: 33
|
|
17
17
|
};
|
|
18
18
|
const PREFIX = 'did:key:z';
|
|
19
|
+
// ES256 is the largest supported did:key payload: a 2-byte codec plus a 33-byte key
|
|
20
|
+
// encodes to 48 base58 characters. Bound before decoding — base58.decode is O(n^2).
|
|
21
|
+
const MAX_DID_KEY_ENCODED = 64;
|
|
19
22
|
function isCodecMatch(codec, bytes) {
|
|
20
23
|
if (bytes.length < codec.length) return false;
|
|
21
24
|
for(let i = 0; i < codec.length; i++){
|
|
@@ -48,7 +51,11 @@ function isCodecMatch(codec, bytes) {
|
|
|
48
51
|
if (!did.startsWith(PREFIX)) {
|
|
49
52
|
throw new Error('Invalid DID format');
|
|
50
53
|
}
|
|
51
|
-
const
|
|
54
|
+
const encoded = did.slice(PREFIX.length);
|
|
55
|
+
if (encoded.length > MAX_DID_KEY_ENCODED) {
|
|
56
|
+
throw new Error('Invalid DID format: key too large');
|
|
57
|
+
}
|
|
58
|
+
const bytes = base58.decode(encoded);
|
|
52
59
|
const info = getAlgorithmAndPublicKey(bytes);
|
|
53
60
|
if (info == null) {
|
|
54
61
|
throw new Error('Unsupported DID signature codec');
|
|
@@ -86,6 +93,7 @@ function isCodecMatch(codec, bytes) {
|
|
|
86
93
|
if (doc == null) {
|
|
87
94
|
throw new Error(`Unknown DID: ${shortForm}`);
|
|
88
95
|
}
|
|
96
|
+
assertDocWithinMaxSize(doc);
|
|
89
97
|
const expected = encodePeer4(doc).shortForm;
|
|
90
98
|
if (expected !== shortForm) {
|
|
91
99
|
throw new Error('DIDResolver: short form/doc hash mismatch');
|
|
@@ -123,6 +131,17 @@ function resolveKidOrAuth(doc, kid) {
|
|
|
123
131
|
}
|
|
124
132
|
return resolveKidFromDoc(doc, auth[0]);
|
|
125
133
|
}
|
|
134
|
+
// A kid must reference a method the subject authorized for `authentication`. Without this
|
|
135
|
+
// check a key listed only under `assertionMethod` (or any other relationship) could sign
|
|
136
|
+
// tokens. `KidNotFound` still takes precedence so a genuinely absent kid is reported as such.
|
|
137
|
+
const method = doc.verificationMethod.find((m)=>m.id === kid);
|
|
138
|
+
if (method == null) {
|
|
139
|
+
throw new Error(`KidNotFound: ${kid}`);
|
|
140
|
+
}
|
|
141
|
+
const auth = doc.authentication;
|
|
142
|
+
if (auth == null || !auth.includes(kid)) {
|
|
143
|
+
throw new Error(`resolveIssuer: kid ${kid} is not an authentication method`);
|
|
144
|
+
}
|
|
126
145
|
return resolveKidFromDoc(doc, kid);
|
|
127
146
|
}
|
|
128
147
|
function resolveKidFromDoc(doc, kid) {
|
|
@@ -130,6 +149,9 @@ function resolveKidFromDoc(doc, kid) {
|
|
|
130
149
|
if (method == null) {
|
|
131
150
|
throw new Error(`KidNotFound: ${kid}`);
|
|
132
151
|
}
|
|
152
|
+
if (method.publicKeyMultibase.length > MAX_DID_KEY_ENCODED) {
|
|
153
|
+
throw new Error('Invalid verification method: key too large');
|
|
154
|
+
}
|
|
133
155
|
const bytes = decodeMultibase(method.publicKeyMultibase);
|
|
134
156
|
const info = getAlgorithmAndPublicKey(bytes);
|
|
135
157
|
if (info == null) {
|
package/lib/identity.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { createTracer, KokuinAttributeKeys, KokuinSpanNames } from '@kokuin/otel';
|
|
1
2
|
import { ed25519, x25519 } from '@noble/curves/ed25519.js';
|
|
2
3
|
import { b64uFromJSON, fromUTF, toB64U } from '@sozai/codec';
|
|
3
|
-
import {
|
|
4
|
+
import { withSpan } from '@sozai/otel';
|
|
4
5
|
import { CODECS, getDID, normalizeDID } from './did.js';
|
|
5
6
|
import { decryptToken } from './jwe.js';
|
|
6
7
|
import { encodeMultibase } from './multibase.js';
|
|
@@ -25,10 +26,10 @@ export function isOwnIdentity(identity) {
|
|
|
25
26
|
const publicKey = ed25519.getPublicKey(privateKey);
|
|
26
27
|
const id = getDID(CODECS.EdDSA, publicKey);
|
|
27
28
|
async function signToken(payload, options = {}) {
|
|
28
|
-
return withSpan(tracer,
|
|
29
|
+
return withSpan(tracer, KokuinSpanNames.TOKEN_SIGN, {
|
|
29
30
|
attributes: {
|
|
30
|
-
[
|
|
31
|
-
[
|
|
31
|
+
[KokuinAttributeKeys.AUTH_DID]: id,
|
|
32
|
+
[KokuinAttributeKeys.AUTH_ALGORITHM]: 'EdDSA'
|
|
32
33
|
}
|
|
33
34
|
}, async ()=>{
|
|
34
35
|
if (payload.iss != null && payload.iss !== id) {
|
package/lib/jwe.js
CHANGED
|
@@ -235,7 +235,8 @@ export function createTokenEncrypter(recipient, options) {
|
|
|
235
235
|
if (parts.length === 3) {
|
|
236
236
|
// JWT: could be plain, jws, or jwe-in-jws
|
|
237
237
|
const token = await verifyToken(message, {
|
|
238
|
-
verifiers: options.verifiers
|
|
238
|
+
verifiers: options.verifiers,
|
|
239
|
+
allowUnsigned: true
|
|
239
240
|
});
|
|
240
241
|
if (isUnsignedToken(token)) {
|
|
241
242
|
return {
|
package/lib/peer4.d.ts
CHANGED
|
@@ -81,9 +81,14 @@ export type DIDDoc = FromSchema<typeof didDocSchema>;
|
|
|
81
81
|
*/
|
|
82
82
|
export declare function validateDIDDoc(value: unknown): value is DIDDoc;
|
|
83
83
|
export type DecodePeer4Options = {
|
|
84
|
-
/** Maximum allowed size of the canonical doc bytes. Default
|
|
84
|
+
/** Maximum allowed size of the canonical doc bytes. Default 4 KiB. */
|
|
85
85
|
maxDocSize?: number;
|
|
86
86
|
};
|
|
87
|
+
/**
|
|
88
|
+
* Throw if a DID document's canonical serialization exceeds `maxSize` bytes.
|
|
89
|
+
* Used to bound a resolver-returned doc before it reaches the O(n^2) base58 encode.
|
|
90
|
+
*/
|
|
91
|
+
export declare function assertDocWithinMaxSize(doc: DIDDoc, maxSize?: number): void;
|
|
87
92
|
/**
|
|
88
93
|
* Check whether a value is a did:peer:4 identifier (either form).
|
|
89
94
|
*/
|
package/lib/peer4.js
CHANGED
|
@@ -78,7 +78,20 @@ const JSON_MULTICODEC = new Uint8Array([
|
|
|
78
78
|
0x80,
|
|
79
79
|
0x04
|
|
80
80
|
]);
|
|
81
|
-
const DEFAULT_MAX_DOC_SIZE =
|
|
81
|
+
const DEFAULT_MAX_DOC_SIZE = 4 * 1024;
|
|
82
|
+
// base58 expands bytes to characters by log(256) / log(58).
|
|
83
|
+
const BASE58_EXPANSION = 1.3658;
|
|
84
|
+
// A SHA-256 multihash is 34 bytes: 47 base58 characters plus the multibase 'z'.
|
|
85
|
+
const MAX_HASH_ENCODED = 64;
|
|
86
|
+
/**
|
|
87
|
+
* Throw if a DID document's canonical serialization exceeds `maxSize` bytes.
|
|
88
|
+
* Used to bound a resolver-returned doc before it reaches the O(n^2) base58 encode.
|
|
89
|
+
*/ export function assertDocWithinMaxSize(doc, maxSize = DEFAULT_MAX_DOC_SIZE) {
|
|
90
|
+
const size = fromUTF(canonicalStringify(doc)).length;
|
|
91
|
+
if (size > maxSize) {
|
|
92
|
+
throw new Error(`did:peer:4 resolver doc too large: ${size} > ${maxSize}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
82
95
|
/**
|
|
83
96
|
* Check whether a value is a did:peer:4 identifier (either form).
|
|
84
97
|
*/ export function isPeer4(value) {
|
|
@@ -109,9 +122,14 @@ function startsWithJsonCodec(bytes) {
|
|
|
109
122
|
}
|
|
110
123
|
const hashEncoded = longForm.slice(PEER4_PREFIX.length, sep);
|
|
111
124
|
const encodedDoc = longForm.slice(sep + 1);
|
|
125
|
+
// Both segments feed an O(n^2) base58 decode, so bound them before decoding.
|
|
126
|
+
if (hashEncoded.length > MAX_HASH_ENCODED) {
|
|
127
|
+
throw new Error('did:peer:4 hash too large');
|
|
128
|
+
}
|
|
112
129
|
const maxSize = options.maxDocSize ?? DEFAULT_MAX_DOC_SIZE;
|
|
113
|
-
|
|
114
|
-
|
|
130
|
+
const maxEncoded = Math.ceil((maxSize + JSON_MULTICODEC.length) * BASE58_EXPANSION) + 8;
|
|
131
|
+
if (encodedDoc.length > maxEncoded) {
|
|
132
|
+
throw new Error(`did:peer:4 encoded doc too large: ${encodedDoc.length} > ${maxEncoded}`);
|
|
115
133
|
}
|
|
116
134
|
const hashBytes = decodeMultibase(hashEncoded);
|
|
117
135
|
if (!verifyMultihash(hashBytes, fromUTF(encodedDoc))) {
|
package/lib/token.d.ts
CHANGED
|
@@ -8,6 +8,24 @@ export type VerifyTokenOptions = TimeValidationOptions & {
|
|
|
8
8
|
verifiers?: Verifiers;
|
|
9
9
|
resolver?: DIDResolver;
|
|
10
10
|
cache?: DIDCache;
|
|
11
|
+
/**
|
|
12
|
+
* Expected audience(s) for the token. When set, verification rejects a token whose `aud`
|
|
13
|
+
* claim is not among the given value(s), and rejects unsigned / `alg:none` tokens outright
|
|
14
|
+
* (they carry no proof of their claims). Intended for the invocation/leaf token directed at
|
|
15
|
+
* a service — it must NOT be forwarded into capability-chain verification, where a
|
|
16
|
+
* capability's `aud` is the delegation next-hop rather than a service audience. An empty
|
|
17
|
+
* array accepts no audience (every token is rejected).
|
|
18
|
+
*/
|
|
19
|
+
audience?: string | Array<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Accept unsigned (`alg:none`) tokens. Defaults to `false`.
|
|
22
|
+
*
|
|
23
|
+
* An unsigned token carries no proof of its claims: its payload is entirely
|
|
24
|
+
* attacker-chosen. With the default, `verifyToken` rejects them and its return type is
|
|
25
|
+
* `VerifiedToken`, so a caller cannot reach an unverified payload by accident. Only opt
|
|
26
|
+
* in when the payload is not used for authorization.
|
|
27
|
+
*/
|
|
28
|
+
allowUnsigned?: boolean;
|
|
11
29
|
};
|
|
12
30
|
export type VerifySignedPayloadInput<Payload extends Record<string, unknown> = Record<string, unknown>> = {
|
|
13
31
|
signature: Uint8Array;
|
|
@@ -32,7 +50,7 @@ export declare function isSignedToken<Payload extends SignedPayload = SignedPayl
|
|
|
32
50
|
/**
|
|
33
51
|
* Check if a token is unsigned.
|
|
34
52
|
*/
|
|
35
|
-
export declare function isUnsignedToken<Payload extends Record<string, unknown>>(token:
|
|
53
|
+
export declare function isUnsignedToken<Payload extends Record<string, unknown>>(token: unknown): token is UnsignedToken<Payload>;
|
|
36
54
|
/**
|
|
37
55
|
* Check if a token was verified by `verifyToken` in this process.
|
|
38
56
|
* A `verifiedPublicKey` property on a deserialized token is never trusted.
|
|
@@ -50,4 +68,10 @@ export declare function signToken<Payload extends Record<string, unknown>, Heade
|
|
|
50
68
|
* Verify a token is either unsigned or signed with a valid signature.
|
|
51
69
|
* Also validates time-based claims (exp, nbf) if present.
|
|
52
70
|
*/
|
|
53
|
-
export declare function verifyToken<Payload extends Record<string, unknown> = Record<string, unknown>>(token: Token<Payload> | string, options?: VerifyTokenOptions
|
|
71
|
+
export declare function verifyToken<Payload extends Record<string, unknown> = Record<string, unknown>>(token: Token<Payload> | string, options?: VerifyTokenOptions & {
|
|
72
|
+
allowUnsigned?: false;
|
|
73
|
+
}): Promise<VerifiedToken<Payload>>;
|
|
74
|
+
export declare function verifyToken<Payload extends Record<string, unknown> = Record<string, unknown>>(token: Token<Payload> | string, options: VerifyTokenOptions & {
|
|
75
|
+
allowUnsigned: true;
|
|
76
|
+
}): Promise<Token<Payload>>;
|
|
77
|
+
export declare function verifyToken<Payload extends Record<string, unknown> = Record<string, unknown>>(token: Token<Payload> | string, options: VerifyTokenOptions): Promise<Token<Payload>>;
|
package/lib/token.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { createTracer, KokuinAttributeKeys, KokuinSpanNames } from '@kokuin/otel';
|
|
1
2
|
import { b64uFromJSON, b64uToJSON, canonicalStringify, fromB64U, fromUTF } from '@sozai/codec';
|
|
2
|
-
import {
|
|
3
|
+
import { withSpan } from '@sozai/otel';
|
|
3
4
|
import { assertType, isType } from '@sozai/schema';
|
|
4
5
|
import { resolveIssuerWithDoc } from './did.js';
|
|
5
6
|
import { validateAlgorithm, validateSignedHeader, validateSignedPayload, validateUnsignedHeader } from './schemas.js';
|
|
@@ -10,6 +11,30 @@ const tokenTracer = createTracer('token');
|
|
|
10
11
|
// `verifiedPublicKey` property but can never be a member of this set, so verification
|
|
11
12
|
// is only ever skipped for objects produced by `verifyToken` itself.
|
|
12
13
|
const verifiedTokens = new WeakSet();
|
|
14
|
+
function assertAudienceValid(payload, audience) {
|
|
15
|
+
if (audience == null) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const aud = payload.aud;
|
|
19
|
+
const allowed = Array.isArray(audience) ? audience : [
|
|
20
|
+
audience
|
|
21
|
+
];
|
|
22
|
+
if (typeof aud !== 'string' || !allowed.includes(aud)) {
|
|
23
|
+
throw new Error(`Invalid token: audience ${typeof aud === 'string' ? `"${aud}"` : 'missing'} not accepted`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// An unsigned / `alg:none` token carries no proof of its claims, so it cannot satisfy an
|
|
27
|
+
// audience requirement. Reject it rather than silently skipping the check.
|
|
28
|
+
function assertSignedForAudience(audience) {
|
|
29
|
+
if (audience != null) {
|
|
30
|
+
throw new Error('Invalid token: audience validation requires a signed token');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function assertUnsignedAllowed(allowUnsigned) {
|
|
34
|
+
if (!allowUnsigned) {
|
|
35
|
+
throw new Error('Invalid token: unsigned tokens rejected, pass allowUnsigned to accept');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
13
38
|
/**
|
|
14
39
|
* Verify the signature of a signed payload and return the public key of the issuer.
|
|
15
40
|
*/ export async function verifySignedPayload(input) {
|
|
@@ -37,12 +62,18 @@ const verifiedTokens = new WeakSet();
|
|
|
37
62
|
/**
|
|
38
63
|
* Check if a token is signed.
|
|
39
64
|
*/ export function isSignedToken(token) {
|
|
65
|
+
if (typeof token !== 'object' || token === null) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
40
68
|
const t = token;
|
|
41
69
|
return isType(validateSignedHeader, t.header) && isType(validateSignedPayload, t.payload) && t.signature != null;
|
|
42
70
|
}
|
|
43
71
|
/**
|
|
44
72
|
* Check if a token is unsigned.
|
|
45
73
|
*/ export function isUnsignedToken(token) {
|
|
74
|
+
if (typeof token !== 'object' || token === null) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
46
77
|
return isType(validateUnsignedHeader, token.header);
|
|
47
78
|
}
|
|
48
79
|
/**
|
|
@@ -92,13 +123,17 @@ function getVerifiableData(token) {
|
|
|
92
123
|
throw new Error('Invalid token: data does not match header and payload');
|
|
93
124
|
}
|
|
94
125
|
async function verifyTokenInner(token, options = {}) {
|
|
95
|
-
const { verifiers, resolver, cache, ...timeOptions } = options;
|
|
126
|
+
const { verifiers, resolver, cache, audience, allowUnsigned = false, ...timeOptions } = options;
|
|
96
127
|
if (typeof token !== 'string') {
|
|
97
128
|
if (isUnsignedToken(token)) {
|
|
129
|
+
assertSignedForAudience(audience);
|
|
130
|
+
assertUnsignedAllowed(allowUnsigned);
|
|
131
|
+
assertTimeClaimsValid(token.payload, timeOptions);
|
|
98
132
|
return token;
|
|
99
133
|
}
|
|
100
134
|
if (isVerifiedToken(token)) {
|
|
101
135
|
assertTimeClaimsValid(token.payload, timeOptions);
|
|
136
|
+
assertAudienceValid(token.payload, audience);
|
|
102
137
|
return token;
|
|
103
138
|
}
|
|
104
139
|
if (isSignedToken(token)) {
|
|
@@ -113,6 +148,7 @@ async function verifyTokenInner(token, options = {}) {
|
|
|
113
148
|
cache
|
|
114
149
|
});
|
|
115
150
|
assertTimeClaimsValid(token.payload, timeOptions);
|
|
151
|
+
assertAudienceValid(token.payload, audience);
|
|
116
152
|
const result = {
|
|
117
153
|
...token,
|
|
118
154
|
data,
|
|
@@ -133,9 +169,13 @@ async function verifyTokenInner(token, options = {}) {
|
|
|
133
169
|
throw new Error('Invalid token header type');
|
|
134
170
|
}
|
|
135
171
|
if (header.alg === 'none') {
|
|
172
|
+
assertSignedForAudience(audience);
|
|
173
|
+
assertUnsignedAllowed(allowUnsigned);
|
|
174
|
+
const payload = b64uToJSON(encodedPayload);
|
|
175
|
+
assertTimeClaimsValid(payload, timeOptions);
|
|
136
176
|
return {
|
|
137
177
|
header,
|
|
138
|
-
payload
|
|
178
|
+
payload
|
|
139
179
|
};
|
|
140
180
|
}
|
|
141
181
|
if (isType(validateAlgorithm, header.alg)) {
|
|
@@ -154,6 +194,7 @@ async function verifyTokenInner(token, options = {}) {
|
|
|
154
194
|
cache
|
|
155
195
|
});
|
|
156
196
|
assertTimeClaimsValid(payload, timeOptions);
|
|
197
|
+
assertAudienceValid(payload, audience);
|
|
157
198
|
const result = {
|
|
158
199
|
data,
|
|
159
200
|
header,
|
|
@@ -166,15 +207,12 @@ async function verifyTokenInner(token, options = {}) {
|
|
|
166
207
|
}
|
|
167
208
|
throw new Error('Unsupported signature algorithm');
|
|
168
209
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
* Also validates time-based claims (exp, nbf) if present.
|
|
172
|
-
*/ export async function verifyToken(token, options = {}) {
|
|
173
|
-
return withSpan(tokenTracer, SpanNames.TOKEN_VERIFY, {}, async (span)=>{
|
|
210
|
+
export async function verifyToken(token, options = {}) {
|
|
211
|
+
return withSpan(tokenTracer, KokuinSpanNames.TOKEN_VERIFY, {}, async (span)=>{
|
|
174
212
|
const result = await verifyTokenInner(token, options);
|
|
175
213
|
if (isSignedToken(result)) {
|
|
176
|
-
span.setAttribute(
|
|
177
|
-
span.setAttribute(
|
|
214
|
+
span.setAttribute(KokuinAttributeKeys.AUTH_DID, result.payload.iss);
|
|
215
|
+
span.setAttribute(KokuinAttributeKeys.AUTH_ALGORITHM, result.header.alg);
|
|
178
216
|
}
|
|
179
217
|
return result;
|
|
180
218
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kokuin/token",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "JWT signing and verification",
|
|
6
6
|
"keywords": [
|
|
@@ -26,15 +26,18 @@
|
|
|
26
26
|
],
|
|
27
27
|
"sideEffects": false,
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@sozai/codec": "^0.1.0",
|
|
30
|
-
"@sozai/schema": "^0.1.0",
|
|
31
|
-
"@sozai/otel": "^0.1.0",
|
|
32
29
|
"@noble/ciphers": "^2.1.1",
|
|
33
30
|
"@noble/curves": "^2.0.1",
|
|
34
31
|
"@noble/hashes": "^2.0.0",
|
|
35
|
-
"@scure/base": "^2.2.0"
|
|
32
|
+
"@scure/base": "^2.2.0",
|
|
33
|
+
"@sozai/codec": "^0.2.0",
|
|
34
|
+
"@sozai/otel": "^0.2.0",
|
|
35
|
+
"@sozai/schema": "^0.1.1",
|
|
36
|
+
"@kokuin/otel": "^0.1.0"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
39
|
+
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
40
|
+
"@opentelemetry/sdk-trace-node": "^2.9.0",
|
|
38
41
|
"uint8arrays": "^6.1.1"
|
|
39
42
|
},
|
|
40
43
|
"scripts": {
|