rpc-express-toolkit 4.4.2 → 4.5.1
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/LICENSE +0 -0
- package/README.md +4 -2
- package/docs/COMPATIBILITY.md +0 -0
- package/examples/example.js +0 -0
- package/examples/introspection/client.js +0 -0
- package/examples/introspection/server.js +0 -0
- package/examples/safe-preset-example.js +0 -0
- package/examples/server-client-example.js +0 -0
- package/examples/ssl-development-example.js +0 -0
- package/package.json +1 -1
- package/src/batch.js +42 -6
- package/src/index.d.ts +20 -3
- package/src/index.js +33 -5
- package/src/logger.js +0 -0
- package/src/memory-rate-limit-store.js +28 -0
- package/src/middleware.js +49 -24
- package/src/protocol.js +0 -0
- package/src/safe.d.ts +2 -0
- package/src/safe.js +1 -0
- package/src/validation.js +0 -0
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -201,7 +201,7 @@ Safe type disambiguation is disabled by default for maximum JSON-RPC 2.0 compati
|
|
|
201
201
|
|
|
202
202
|
## Authentication And Method Restrictions
|
|
203
203
|
|
|
204
|
-
Authentication is supported through the built-in `auth` middleware. The `auth` option is a function that receives the Express request. Returning a truthy value allows the RPC call;
|
|
204
|
+
Authentication is supported through the built-in `auth` middleware. The `auth` option is a function that receives the Express request. Returning a truthy value allows the RPC call; a truthy non-boolean value is available to handlers as the optional fourth argument's `authenticatedUser`. Returning a falsy value rejects the call with JSON-RPC error `-32001` and message `Authentication required`. Throwing also rejects the call and returns a JSON-RPC error based on the thrown error.
|
|
205
205
|
|
|
206
206
|
```js
|
|
207
207
|
const rpc = new RpcEndpoint(app, context, {
|
|
@@ -211,7 +211,9 @@ const rpc = new RpcEndpoint(app, context, {
|
|
|
211
211
|
},
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
-
rpc.addMethod('
|
|
214
|
+
rpc.addMethod('whoami', (_req, _context, _params, callContext) =>
|
|
215
|
+
callContext.authenticatedUser
|
|
216
|
+
);
|
|
215
217
|
```
|
|
216
218
|
|
|
217
219
|
For simple method-level restriction, use `methodWhitelist`:
|
package/docs/COMPATIBILITY.md
CHANGED
|
File without changes
|
package/examples/example.js
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rpc-express-toolkit",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.1",
|
|
4
4
|
"description": "JSON-RPC 2.0 toolkit for Express.js with simplified APIs, structured logging, middleware, schema validation, batch support, and optional Safe Mode.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
package/src/batch.js
CHANGED
|
@@ -52,6 +52,26 @@ class BatchHandler {
|
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
if (!this.endpoint.options.enableBatch) {
|
|
56
|
+
return {
|
|
57
|
+
valid: false,
|
|
58
|
+
error: {
|
|
59
|
+
code: -32600,
|
|
60
|
+
message: 'Invalid Request: Batch requests are not enabled',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (batch.length > this.endpoint.options.maxBatchSize) {
|
|
66
|
+
return {
|
|
67
|
+
valid: false,
|
|
68
|
+
error: {
|
|
69
|
+
code: -32600,
|
|
70
|
+
message: `Invalid Request: Batch size exceeds maximum of ${this.endpoint.options.maxBatchSize}`,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
return { valid: true };
|
|
56
76
|
}
|
|
57
77
|
|
|
@@ -76,7 +96,13 @@ class BatchHandler {
|
|
|
76
96
|
// Process all requests in parallel
|
|
77
97
|
const promises = batch.map(async (request, index) => {
|
|
78
98
|
try {
|
|
79
|
-
return await this.processSingleRequest(
|
|
99
|
+
return await this.processSingleRequest(
|
|
100
|
+
request,
|
|
101
|
+
req,
|
|
102
|
+
res,
|
|
103
|
+
context,
|
|
104
|
+
index
|
|
105
|
+
);
|
|
80
106
|
} catch (error) {
|
|
81
107
|
const errorData = this.serializeErrorData(error, index);
|
|
82
108
|
return {
|
|
@@ -101,11 +127,12 @@ class BatchHandler {
|
|
|
101
127
|
* Process a single request within a batch
|
|
102
128
|
* @param {Object} request
|
|
103
129
|
* @param {Object} req
|
|
130
|
+
* @param {Object} res
|
|
104
131
|
* @param {any} context
|
|
105
132
|
* @param {number} batchIndex
|
|
106
133
|
* @returns {Promise<Object|null>}
|
|
107
134
|
*/
|
|
108
|
-
async processSingleRequest(request, req, context, batchIndex) {
|
|
135
|
+
async processSingleRequest(request, req, res, context, batchIndex) {
|
|
109
136
|
const { method, params, id } = request || {};
|
|
110
137
|
const envelope = validateEnvelope(request);
|
|
111
138
|
|
|
@@ -183,7 +210,7 @@ class BatchHandler {
|
|
|
183
210
|
// Initialize middleware context
|
|
184
211
|
let middlewareContext = {
|
|
185
212
|
req,
|
|
186
|
-
res
|
|
213
|
+
res,
|
|
187
214
|
method,
|
|
188
215
|
params: deserializedParams,
|
|
189
216
|
context,
|
|
@@ -236,14 +263,23 @@ class BatchHandler {
|
|
|
236
263
|
}
|
|
237
264
|
|
|
238
265
|
// Execute the handler
|
|
239
|
-
|
|
240
|
-
handler(
|
|
266
|
+
let result = await Promise.resolve(
|
|
267
|
+
handler(
|
|
268
|
+
req,
|
|
269
|
+
middlewareContext.context,
|
|
270
|
+
middlewareContext.params,
|
|
271
|
+
middlewareContext
|
|
272
|
+
)
|
|
241
273
|
);
|
|
242
274
|
|
|
243
275
|
// Execute after middleware
|
|
244
276
|
if (this.endpoint.middleware) {
|
|
245
277
|
middlewareContext.result = result;
|
|
246
|
-
await this.endpoint.middleware.execute(
|
|
278
|
+
middlewareContext = await this.endpoint.middleware.execute(
|
|
279
|
+
'afterCall',
|
|
280
|
+
middlewareContext
|
|
281
|
+
);
|
|
282
|
+
result = middlewareContext.result;
|
|
247
283
|
}
|
|
248
284
|
|
|
249
285
|
// For notifications, return null (will be filtered out)
|
package/src/index.d.ts
CHANGED
|
@@ -93,7 +93,8 @@ declare namespace RpcEndpoint {
|
|
|
93
93
|
export type JSONRPCHandler<C> = (
|
|
94
94
|
req: Request,
|
|
95
95
|
context: C,
|
|
96
|
-
params: any
|
|
96
|
+
params: any,
|
|
97
|
+
callContext?: MiddlewareContext<C>
|
|
97
98
|
) => any | Promise<any>;
|
|
98
99
|
|
|
99
100
|
/** Method configuration object. */
|
|
@@ -153,10 +154,19 @@ declare namespace RpcEndpoint {
|
|
|
153
154
|
message?: string;
|
|
154
155
|
standardHeaders?: boolean;
|
|
155
156
|
legacyHeaders?: boolean;
|
|
157
|
+
store?: RateLimitStore;
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
/** Authentication verifier.
|
|
159
|
-
export type AuthConfig = (req: Request) =>
|
|
160
|
+
/** Authentication verifier. A truthy non-boolean result becomes authenticatedUser. */
|
|
161
|
+
export type AuthConfig = (req: Request) => unknown | Promise<unknown>;
|
|
162
|
+
|
|
163
|
+
export interface RateLimitStore {
|
|
164
|
+
increment(
|
|
165
|
+
key: string,
|
|
166
|
+
windowMs: number,
|
|
167
|
+
now: number
|
|
168
|
+
): number | Promise<number>;
|
|
169
|
+
}
|
|
160
170
|
|
|
161
171
|
/** Schema validation configuration. */
|
|
162
172
|
export interface ValidationConfig {
|
|
@@ -198,6 +208,8 @@ declare namespace RpcEndpoint {
|
|
|
198
208
|
healthCheck?: boolean;
|
|
199
209
|
enableIntrospection?: boolean;
|
|
200
210
|
introspectionPrefix?: string;
|
|
211
|
+
enableBatch?: boolean;
|
|
212
|
+
maxBatchSize?: number;
|
|
201
213
|
}
|
|
202
214
|
|
|
203
215
|
/** RPC client configuration options. */
|
|
@@ -227,6 +239,7 @@ declare namespace RpcEndpoint {
|
|
|
227
239
|
batchIndex?: number;
|
|
228
240
|
isNotification?: boolean;
|
|
229
241
|
startTime?: number;
|
|
242
|
+
authenticatedUser?: unknown;
|
|
230
243
|
[key: string]: any;
|
|
231
244
|
}
|
|
232
245
|
|
|
@@ -258,6 +271,10 @@ declare namespace RpcEndpoint {
|
|
|
258
271
|
new (...args: any[]): MiddlewareManager;
|
|
259
272
|
};
|
|
260
273
|
|
|
274
|
+
export const MemoryRateLimitStore: {
|
|
275
|
+
new (): RateLimitStore;
|
|
276
|
+
};
|
|
277
|
+
|
|
261
278
|
export const builtInMiddlewares: any;
|
|
262
279
|
|
|
263
280
|
export interface SchemaValidationResult<T = any> {
|
package/src/index.js
CHANGED
|
@@ -20,7 +20,11 @@ const DEFAULT_MAX_SERIALIZATION_DEPTH = 100;
|
|
|
20
20
|
/** @typedef {import("express").Response} Response */
|
|
21
21
|
|
|
22
22
|
const Logger = require('./logger');
|
|
23
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
MiddlewareManager,
|
|
25
|
+
MemoryRateLimitStore,
|
|
26
|
+
builtInMiddlewares,
|
|
27
|
+
} = require('./middleware');
|
|
24
28
|
const {
|
|
25
29
|
SchemaValidator,
|
|
26
30
|
commonSchemas,
|
|
@@ -165,6 +169,12 @@ class RpcEndpoint {
|
|
|
165
169
|
this.#options.enableIntrospection === true; // Default false
|
|
166
170
|
this.#options.introspectionPrefix =
|
|
167
171
|
this.#options.introspectionPrefix || '__rpc'; // Default __rpc
|
|
172
|
+
this.#options.enableBatch = this.#options.enableBatch !== false;
|
|
173
|
+
this.#options.maxBatchSize =
|
|
174
|
+
Number.isInteger(this.#options.maxBatchSize) &&
|
|
175
|
+
this.#options.maxBatchSize > 0
|
|
176
|
+
? this.#options.maxBatchSize
|
|
177
|
+
: 100;
|
|
168
178
|
|
|
169
179
|
this.#introspectionPrefix = this.#options.introspectionPrefix;
|
|
170
180
|
|
|
@@ -334,7 +344,7 @@ class RpcEndpoint {
|
|
|
334
344
|
`${this.#introspectionPrefix}.capabilities`,
|
|
335
345
|
async (_req, _context, _params) => ({
|
|
336
346
|
safeMode: this.#options.safeEnabled,
|
|
337
|
-
batch:
|
|
347
|
+
batch: this.#options.enableBatch,
|
|
338
348
|
introspection: true,
|
|
339
349
|
introspectionPrefix: this.#introspectionPrefix,
|
|
340
350
|
validation: !!this.#options.validation,
|
|
@@ -431,6 +441,14 @@ class RpcEndpoint {
|
|
|
431
441
|
});
|
|
432
442
|
}
|
|
433
443
|
|
|
444
|
+
if (this.#options.cors) {
|
|
445
|
+
const corsMiddleware = builtInMiddlewares.cors(this.#options.cors);
|
|
446
|
+
router.options(this.#endpoint, async (req, res) => {
|
|
447
|
+
await corsMiddleware({ req, res });
|
|
448
|
+
res.status(204).end();
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
434
452
|
// Main JSON-RPC endpoint
|
|
435
453
|
router.post(this.#endpoint, async (req, res) => {
|
|
436
454
|
const startTime = Date.now();
|
|
@@ -629,13 +647,22 @@ class RpcEndpoint {
|
|
|
629
647
|
}
|
|
630
648
|
|
|
631
649
|
// Invoke the handler
|
|
632
|
-
|
|
633
|
-
handler(
|
|
650
|
+
let result = await Promise.resolve(
|
|
651
|
+
handler(
|
|
652
|
+
req,
|
|
653
|
+
middlewareContext.context,
|
|
654
|
+
middlewareContext.params,
|
|
655
|
+
middlewareContext
|
|
656
|
+
)
|
|
634
657
|
);
|
|
635
658
|
|
|
636
659
|
// Execute afterCall middleware
|
|
637
660
|
middlewareContext.result = result;
|
|
638
|
-
await this.#middleware.execute(
|
|
661
|
+
middlewareContext = await this.#middleware.execute(
|
|
662
|
+
'afterCall',
|
|
663
|
+
middlewareContext
|
|
664
|
+
);
|
|
665
|
+
result = middlewareContext.result;
|
|
639
666
|
|
|
640
667
|
// Log success
|
|
641
668
|
const duration = Date.now() - startTime;
|
|
@@ -1153,6 +1180,7 @@ module.exports.RpcHttpError = RpcHttpError;
|
|
|
1153
1180
|
module.exports.RpcSafeClient = RpcSafeClient;
|
|
1154
1181
|
module.exports.Logger = Logger;
|
|
1155
1182
|
module.exports.MiddlewareManager = MiddlewareManager;
|
|
1183
|
+
module.exports.MemoryRateLimitStore = MemoryRateLimitStore;
|
|
1156
1184
|
module.exports.builtInMiddlewares = builtInMiddlewares;
|
|
1157
1185
|
module.exports.SchemaValidator = SchemaValidator;
|
|
1158
1186
|
module.exports.commonSchemas = commonSchemas;
|
package/src/logger.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process sliding-window store used by the rate limiter by default.
|
|
3
|
+
* Multi-process and multi-instance deployments should provide a shared store
|
|
4
|
+
* implementing the same increment() contract.
|
|
5
|
+
*/
|
|
6
|
+
class MemoryRateLimitStore {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.requests = new Map();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Record a hit and return the number of hits still inside the window.
|
|
13
|
+
* @param {string} key
|
|
14
|
+
* @param {number} windowMs
|
|
15
|
+
* @param {number} [now]
|
|
16
|
+
* @returns {Promise<number>}
|
|
17
|
+
*/
|
|
18
|
+
async increment(key, windowMs, now = Date.now()) {
|
|
19
|
+
const validRequests = (this.requests.get(key) || []).filter(
|
|
20
|
+
(time) => now - time < windowMs
|
|
21
|
+
);
|
|
22
|
+
validRequests.push(now);
|
|
23
|
+
this.requests.set(key, validRequests);
|
|
24
|
+
return validRequests.length;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = MemoryRateLimitStore;
|
package/src/middleware.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* @description Handles middleware execution for JSON-RPC requests including built-in middleware
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const MemoryRateLimitStore = require('./memory-rate-limit-store');
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Middleware manager for handling RPC middleware
|
|
8
10
|
*/
|
|
@@ -138,33 +140,27 @@ const builtInMiddlewares = {
|
|
|
138
140
|
const max = options.max || 100; // 100 requests per window
|
|
139
141
|
const message = options.message || 'Too many requests';
|
|
140
142
|
|
|
141
|
-
const
|
|
143
|
+
const store = options.store || new MemoryRateLimitStore();
|
|
144
|
+
|
|
145
|
+
if (!store || typeof store.increment !== 'function') {
|
|
146
|
+
throw new TypeError(
|
|
147
|
+
'Rate limit store must implement increment(key, windowMs, now)'
|
|
148
|
+
);
|
|
149
|
+
}
|
|
142
150
|
|
|
143
151
|
return async (context) => {
|
|
144
152
|
const { req } = context;
|
|
145
153
|
const key = req.ip || req.connection?.remoteAddress || 'unknown';
|
|
146
154
|
const now = Date.now();
|
|
147
155
|
|
|
148
|
-
|
|
149
|
-
if (requests.has(key)) {
|
|
150
|
-
const userRequests = requests.get(key);
|
|
151
|
-
const validRequests = userRequests.filter(
|
|
152
|
-
(time) => now - time < windowMs
|
|
153
|
-
);
|
|
154
|
-
requests.set(key, validRequests);
|
|
155
|
-
}
|
|
156
|
+
const requestCount = await store.increment(key, windowMs, now);
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (userRequests.length >= max) {
|
|
158
|
+
if (requestCount > max) {
|
|
160
159
|
const error = new Error(message);
|
|
161
160
|
error.code = -32000; // Custom JSON-RPC error code
|
|
162
161
|
throw error;
|
|
163
162
|
}
|
|
164
163
|
|
|
165
|
-
userRequests.push(now);
|
|
166
|
-
requests.set(key, userRequests);
|
|
167
|
-
|
|
168
164
|
return context;
|
|
169
165
|
};
|
|
170
166
|
},
|
|
@@ -177,15 +173,19 @@ const builtInMiddlewares = {
|
|
|
177
173
|
auth(authFunction) {
|
|
178
174
|
return async (context) => {
|
|
179
175
|
const { req } = context;
|
|
180
|
-
const
|
|
176
|
+
const authenticatedUser = await authFunction(req);
|
|
181
177
|
|
|
182
|
-
if (!
|
|
178
|
+
if (!authenticatedUser) {
|
|
183
179
|
const error = new Error('Authentication required');
|
|
184
180
|
error.code = -32001;
|
|
185
181
|
throw error;
|
|
186
182
|
}
|
|
187
183
|
|
|
188
|
-
return
|
|
184
|
+
return {
|
|
185
|
+
...context,
|
|
186
|
+
authenticatedUser:
|
|
187
|
+
authenticatedUser === true ? undefined : authenticatedUser,
|
|
188
|
+
};
|
|
189
189
|
};
|
|
190
190
|
},
|
|
191
191
|
|
|
@@ -195,16 +195,37 @@ const builtInMiddlewares = {
|
|
|
195
195
|
* @returns {Function}
|
|
196
196
|
*/
|
|
197
197
|
cors(options = {}) {
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
const
|
|
198
|
+
const config = options === true ? {} : options;
|
|
199
|
+
const origin = config.origin ?? '*';
|
|
200
|
+
const methods = config.methods || ['POST'];
|
|
201
|
+
const headers = config.headers || config.allowedHeaders || ['Content-Type'];
|
|
201
202
|
|
|
202
203
|
return async (context) => {
|
|
203
|
-
const { res } = context;
|
|
204
|
+
const { req, res } = context;
|
|
205
|
+
const requestOrigin = req?.headers?.origin;
|
|
206
|
+
let allowedOrigin = origin;
|
|
204
207
|
|
|
205
|
-
|
|
208
|
+
if (origin === false) {
|
|
209
|
+
return context;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (Array.isArray(origin)) {
|
|
213
|
+
if (!requestOrigin || !origin.includes(requestOrigin)) {
|
|
214
|
+
return context;
|
|
215
|
+
}
|
|
216
|
+
allowedOrigin = requestOrigin;
|
|
217
|
+
res.vary('Origin');
|
|
218
|
+
} else if (origin === true) {
|
|
219
|
+
allowedOrigin = requestOrigin || '*';
|
|
220
|
+
if (requestOrigin) res.vary('Origin');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
res.header('Access-Control-Allow-Origin', allowedOrigin);
|
|
206
224
|
res.header('Access-Control-Allow-Methods', methods.join(', '));
|
|
207
225
|
res.header('Access-Control-Allow-Headers', headers.join(', '));
|
|
226
|
+
if (config.credentials === true) {
|
|
227
|
+
res.header('Access-Control-Allow-Credentials', 'true');
|
|
228
|
+
}
|
|
208
229
|
|
|
209
230
|
return context;
|
|
210
231
|
};
|
|
@@ -241,4 +262,8 @@ const builtInMiddlewares = {
|
|
|
241
262
|
},
|
|
242
263
|
};
|
|
243
264
|
|
|
244
|
-
module.exports = {
|
|
265
|
+
module.exports = {
|
|
266
|
+
MiddlewareManager,
|
|
267
|
+
MemoryRateLimitStore,
|
|
268
|
+
builtInMiddlewares,
|
|
269
|
+
};
|
package/src/protocol.js
CHANGED
|
File without changes
|
package/src/safe.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ declare namespace Safe {
|
|
|
12
12
|
export import RpcHttpError = Main.RpcHttpError;
|
|
13
13
|
export import Logger = Main.Logger;
|
|
14
14
|
export import MiddlewareManager = Main.MiddlewareManager;
|
|
15
|
+
export import MemoryRateLimitStore = Main.MemoryRateLimitStore;
|
|
15
16
|
export import builtInMiddlewares = Main.builtInMiddlewares;
|
|
16
17
|
export import SchemaValidator = Main.SchemaValidator;
|
|
17
18
|
export import commonSchemas = Main.commonSchemas;
|
|
@@ -27,6 +28,7 @@ declare namespace Safe {
|
|
|
27
28
|
export import LoggingConfig = Main.LoggingConfig;
|
|
28
29
|
export import CorsConfig = Main.CorsConfig;
|
|
29
30
|
export import RateLimitConfig = Main.RateLimitConfig;
|
|
31
|
+
export import RateLimitStore = Main.RateLimitStore;
|
|
30
32
|
export import AuthConfig = Main.AuthConfig;
|
|
31
33
|
export import ValidationConfig = Main.ValidationConfig;
|
|
32
34
|
export import RpcEndpointOptions = Main.RpcEndpointOptions;
|
package/src/safe.js
CHANGED
|
@@ -119,6 +119,7 @@ module.exports.RpcError = Main.RpcError;
|
|
|
119
119
|
module.exports.RpcHttpError = Main.RpcHttpError;
|
|
120
120
|
module.exports.Logger = Main.Logger;
|
|
121
121
|
module.exports.MiddlewareManager = Main.MiddlewareManager;
|
|
122
|
+
module.exports.MemoryRateLimitStore = Main.MemoryRateLimitStore;
|
|
122
123
|
module.exports.builtInMiddlewares = Main.builtInMiddlewares;
|
|
123
124
|
module.exports.SchemaValidator = Main.SchemaValidator;
|
|
124
125
|
module.exports.commonSchemas = Main.commonSchemas;
|
package/src/validation.js
CHANGED
|
File without changes
|