@floegence/flowersec-core 0.9.0 → 0.10.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.
@@ -2,19 +2,7 @@ import { connectDirect } from "../direct-client/connect.js";
2
2
  import { connectTunnel } from "../tunnel-client/connect.js";
3
3
  import { connect } from "../facade.js";
4
4
  import { createNodeWsFactory } from "./wsFactory.js";
5
- const defaultMaxHandshakePayload = 8 * 1024;
6
- const defaultMaxRecordBytes = 1 << 20;
7
- const handshakeFrameOverheadBytes = 4 + 1 + 1 + 4;
8
- const wsMaxPayloadSlackBytes = 64;
9
- function defaultWsMaxPayload(opts) {
10
- const maxHandshakePayload = opts.maxHandshakePayload ?? 0;
11
- const maxRecordBytes = opts.maxRecordBytes ?? 0;
12
- const hp = Number.isSafeInteger(maxHandshakePayload) && maxHandshakePayload > 0 ? maxHandshakePayload : defaultMaxHandshakePayload;
13
- const rb = Number.isSafeInteger(maxRecordBytes) && maxRecordBytes > 0 ? maxRecordBytes : defaultMaxRecordBytes;
14
- const handshakeMax = Math.min(Number.MAX_SAFE_INTEGER, hp + handshakeFrameOverheadBytes);
15
- const max = Math.max(rb, handshakeMax);
16
- return Math.min(Number.MAX_SAFE_INTEGER, max + wsMaxPayloadSlackBytes);
17
- }
5
+ import { defaultWsMaxPayload } from "./wsDefaults.js";
18
6
  export async function connectNode(input, opts) {
19
7
  const wsFactory = opts.wsFactory ??
20
8
  createNodeWsFactory({
@@ -0,0 +1,4 @@
1
+ export declare function defaultWsMaxPayload(opts: Readonly<{
2
+ maxHandshakePayload?: number;
3
+ maxRecordBytes?: number;
4
+ }>): number;
@@ -0,0 +1,13 @@
1
+ const defaultMaxHandshakePayload = 8 * 1024;
2
+ const defaultMaxRecordBytes = 1 << 20;
3
+ const handshakeFrameOverheadBytes = 4 + 1 + 1 + 4;
4
+ const wsMaxPayloadSlackBytes = 64;
5
+ export function defaultWsMaxPayload(opts) {
6
+ const maxHandshakePayload = opts.maxHandshakePayload ?? 0;
7
+ const maxRecordBytes = opts.maxRecordBytes ?? 0;
8
+ const hp = Number.isSafeInteger(maxHandshakePayload) && maxHandshakePayload > 0 ? maxHandshakePayload : defaultMaxHandshakePayload;
9
+ const rb = Number.isSafeInteger(maxRecordBytes) && maxRecordBytes > 0 ? maxRecordBytes : defaultMaxRecordBytes;
10
+ const handshakeMax = Math.min(Number.MAX_SAFE_INTEGER, hp + handshakeFrameOverheadBytes);
11
+ const max = Math.max(rb, handshakeMax);
12
+ return Math.min(Number.MAX_SAFE_INTEGER, max + wsMaxPayloadSlackBytes);
13
+ }
@@ -1,4 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
+ import { defaultWsMaxPayload } from "./wsDefaults.js";
2
3
  // createNodeWsFactory returns a wsFactory compatible with connectTunnel/connectDirect in Node.js.
3
4
  //
4
5
  // It uses the "ws" package to set the Origin header explicitly (browsers set Origin automatically).
@@ -6,15 +7,16 @@ export function createNodeWsFactory(opts = {}) {
6
7
  const require = createRequire(import.meta.url);
7
8
  const wsMod = require("ws");
8
9
  const WebSocketCtor = wsMod?.WebSocket ?? wsMod;
9
- const maxPayload = opts.maxPayload;
10
- if (maxPayload !== undefined && (!Number.isSafeInteger(maxPayload) || maxPayload <= 0)) {
10
+ const maxPayloadRaw = opts.maxPayload ?? defaultWsMaxPayload({});
11
+ if (!Number.isSafeInteger(maxPayloadRaw) || maxPayloadRaw <= 0) {
11
12
  throw new Error("maxPayload must be a positive integer");
12
13
  }
14
+ const maxPayload = maxPayloadRaw;
13
15
  const perMessageDeflate = opts.perMessageDeflate ?? false;
14
16
  return (url, origin) => {
15
17
  const raw = new WebSocketCtor(url, {
16
18
  headers: { Origin: origin },
17
- ...(maxPayload !== undefined ? { maxPayload } : {}),
19
+ maxPayload,
18
20
  perMessageDeflate,
19
21
  });
20
22
  // Map (type -> user listener -> wrapped listener) so removeEventListener works.
@@ -4,5 +4,3 @@ export declare const PROXY_KIND_WS: "flowersec-proxy/ws";
4
4
  export declare const DEFAULT_MAX_CHUNK_BYTES: number;
5
5
  export declare const DEFAULT_MAX_BODY_BYTES: number;
6
6
  export declare const DEFAULT_MAX_WS_FRAME_BYTES: number;
7
- export declare const DEFAULT_DEFAULT_TIMEOUT_MS = 30000;
8
- export declare const DEFAULT_MAX_TIMEOUT_MS: number;
@@ -4,5 +4,3 @@ export const PROXY_KIND_WS = "flowersec-proxy/ws";
4
4
  export const DEFAULT_MAX_CHUNK_BYTES = 256 * 1024;
5
5
  export const DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
6
6
  export const DEFAULT_MAX_WS_FRAME_BYTES = 1024 * 1024;
7
- export const DEFAULT_DEFAULT_TIMEOUT_MS = 30_000;
8
- export const DEFAULT_MAX_TIMEOUT_MS = 5 * 60_000;
@@ -24,6 +24,8 @@ export type ProxyServiceWorkerInjectHTMLOptions = (ProxyServiceWorkerInjectHTMLI
24
24
  }>;
25
25
  export type ProxyServiceWorkerScriptOptions = Readonly<{
26
26
  sameOriginOnly?: boolean;
27
+ maxRequestBodyBytes?: number;
28
+ maxInjectHTMLBytes?: number;
27
29
  passthrough?: ProxyServiceWorkerPassthroughOptions;
28
30
  proxyPathPrefix?: string;
29
31
  stripProxyPathPrefix?: boolean;
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_MAX_BODY_BYTES } from "./constants.js";
1
2
  function normalizePathPrefix(name, v) {
2
3
  const s = typeof v === "string" ? v.trim() : "";
3
4
  if (s === "")
@@ -24,6 +25,21 @@ function normalizePathList(name, input) {
24
25
  }
25
26
  return Array.from(new Set(out));
26
27
  }
28
+ const defaultMaxInjectHTMLBytes = 2 * 1024 * 1024;
29
+ function normalizeMaxBytes(name, v, defaultValue) {
30
+ if (v == null)
31
+ return defaultValue;
32
+ if (typeof v !== "number" || !Number.isFinite(v))
33
+ throw new Error(`${name} must be a finite number`);
34
+ const n = Math.floor(v);
35
+ if (!Number.isSafeInteger(n))
36
+ throw new Error(`${name} must be a safe integer`);
37
+ if (n < 0)
38
+ throw new Error(`${name} must be >= 0`);
39
+ if (n === 0)
40
+ return defaultValue;
41
+ return n;
42
+ }
27
43
  // createProxyServiceWorkerScript returns a Service Worker script that forwards fetches to a runtime
28
44
  // in a controlled window via postMessage + MessageChannel.
29
45
  //
@@ -33,6 +49,8 @@ export function createProxyServiceWorkerScript(opts = {}) {
33
49
  if (typeof sameOriginOnly !== "boolean") {
34
50
  throw new Error("sameOriginOnly must be a boolean");
35
51
  }
52
+ const maxRequestBodyBytes = normalizeMaxBytes("maxRequestBodyBytes", opts.maxRequestBodyBytes, DEFAULT_MAX_BODY_BYTES);
53
+ const maxInjectHTMLBytes = normalizeMaxBytes("maxInjectHTMLBytes", opts.maxInjectHTMLBytes, defaultMaxInjectHTMLBytes);
36
54
  const proxyPathPrefix = normalizePathPrefix("proxyPathPrefix", opts.proxyPathPrefix);
37
55
  const stripProxyPathPrefix = opts.stripProxyPathPrefix ?? false;
38
56
  if (typeof stripProxyPathPrefix !== "boolean") {
@@ -96,6 +114,9 @@ const INJECT_EXCLUDE_PREFIXES = ${JSON.stringify(excludeInjectPrefixes)};
96
114
  const INJECT_STRIP_VALIDATOR_HEADERS = ${JSON.stringify(stripValidatorHeaders)};
97
115
  const INJECT_SET_NO_STORE = ${JSON.stringify(setNoStore)};
98
116
 
117
+ const MAX_REQUEST_BODY_BYTES = ${JSON.stringify(maxRequestBodyBytes)};
118
+ const MAX_INJECT_HTML_BYTES = ${JSON.stringify(maxInjectHTMLBytes)};
119
+
99
120
  const INJECT_STRIP_HEADER_NAMES = new Set(["content-length", "etag", "last-modified", "content-md5"]);
100
121
 
101
122
  let runtimeClientId = null;
@@ -163,6 +184,54 @@ function concatChunks(chunks) {
163
184
  return out;
164
185
  }
165
186
 
187
+ function makeError(status, message) {
188
+ const s = Math.max(0, Math.floor(status));
189
+ const e = new Error(String(message || "proxy error"));
190
+ e.status = s > 0 ? s : 502;
191
+ return e;
192
+ }
193
+
194
+ function getErrorStatus(err, fallback) {
195
+ const raw =
196
+ err && typeof err === "object" && typeof err.status === "number" && Number.isFinite(err.status)
197
+ ? Math.floor(err.status)
198
+ : fallback;
199
+ return raw > 0 ? raw : 502;
200
+ }
201
+
202
+ async function readRequestBody(req) {
203
+ const clRaw = req.headers.get("content-length");
204
+ const cl = clRaw ? Number(clRaw) : 0;
205
+ if (MAX_REQUEST_BODY_BYTES > 0 && Number.isFinite(cl) && cl > MAX_REQUEST_BODY_BYTES) {
206
+ throw makeError(413, "request body too large");
207
+ }
208
+
209
+ // Prefer streaming reads so we can enforce MAX_REQUEST_BODY_BYTES without allocating unbounded buffers.
210
+ if (!req.body) {
211
+ const ab = await req.arrayBuffer();
212
+ if (MAX_REQUEST_BODY_BYTES > 0 && ab.byteLength > MAX_REQUEST_BODY_BYTES) {
213
+ throw makeError(413, "request body too large");
214
+ }
215
+ return ab;
216
+ }
217
+
218
+ const reader = req.body.getReader();
219
+ const chunks = [];
220
+ let total = 0;
221
+ while (true) {
222
+ const r = await reader.read();
223
+ if (r.done) break;
224
+ const b = r.value;
225
+ total += b.length;
226
+ if (MAX_REQUEST_BODY_BYTES > 0 && total > MAX_REQUEST_BODY_BYTES) {
227
+ try { reader.cancel(); } catch {}
228
+ throw makeError(413, "request body too large");
229
+ }
230
+ chunks.push(b);
231
+ }
232
+ return concatChunks(chunks).buffer;
233
+ }
234
+
166
235
  function injectBootstrap(html) {
167
236
  let snippet = "";
168
237
 
@@ -215,116 +284,167 @@ self.addEventListener("fetch", (event) => {
215
284
  });
216
285
 
217
286
  async function handleFetch(event) {
218
- const runtime = await getRuntimeClient();
219
- if (!runtime) return new Response("flowersec-proxy runtime not available", { status: 503 });
220
-
221
- const req = event.request;
222
- const url = new URL(req.url);
223
- const id = Math.random().toString(16).slice(2) + Date.now().toString(16);
224
- const body = (req.method === "GET" || req.method === "HEAD") ? undefined : await req.arrayBuffer();
225
-
226
- const ch = new MessageChannel();
227
- const port = ch.port1;
228
- const port2 = ch.port2;
229
-
230
- let metaResolve;
231
- let metaReject;
232
- const metaPromise = new Promise((resolve, reject) => { metaResolve = resolve; metaReject = reject; });
233
-
234
- const queued = [];
235
- const htmlChunks = [];
236
- let shouldInjectHTML = false;
237
- const injectAllowed = INJECT_HTML && !shouldSkipInject(url.pathname);
238
-
239
- let doneResolve;
240
- let doneReject;
241
- const donePromise = new Promise((resolve, reject) => { doneResolve = resolve; doneReject = reject; });
242
-
243
- let controller = null;
244
-
245
- const stream = new ReadableStream({
246
- start(c) { controller = c; },
247
- cancel() {
248
- try { port.postMessage({ type: "flowersec-proxy:abort" }); } catch {}
249
- try { port.close(); } catch {}
287
+ let lastErrorStatus = 502;
288
+ let lastErrorMessage = "proxy error";
289
+
290
+ try {
291
+ const runtime = await getRuntimeClient();
292
+ if (!runtime) return new Response("flowersec-proxy runtime not available", { status: 503 });
293
+
294
+ const req = event.request;
295
+ const url = new URL(req.url);
296
+ const id = Math.random().toString(16).slice(2) + Date.now().toString(16);
297
+
298
+ let body;
299
+ if (req.method === "GET" || req.method === "HEAD") {
300
+ body = undefined;
301
+ } else {
302
+ body = await readRequestBody(req);
250
303
  }
251
- });
252
-
253
- port.onmessage = (ev) => {
254
- const m = ev.data;
255
- if (!m || typeof m.type !== "string") return;
256
- if (m.type === "flowersec-proxy:response_meta") {
257
- if (injectAllowed) {
258
- const ct = String((m.headers || []).find((h) => (h.name || "").toLowerCase() === "content-type")?.value || "");
259
- shouldInjectHTML = ct.toLowerCase().includes("text/html");
304
+
305
+ const ch = new MessageChannel();
306
+ const port = ch.port1;
307
+ const port2 = ch.port2;
308
+
309
+ let metaResolve;
310
+ let metaReject;
311
+ const metaPromise = new Promise((resolve, reject) => { metaResolve = resolve; metaReject = reject; });
312
+
313
+ const queued = [];
314
+ const htmlChunks = [];
315
+ let htmlBytes = 0;
316
+ let shouldInjectHTML = false;
317
+ const injectAllowed = INJECT_HTML && !shouldSkipInject(url.pathname);
318
+
319
+ let doneResolve;
320
+ let doneReject;
321
+ const donePromise = new Promise((resolve, reject) => { doneResolve = resolve; doneReject = reject; });
322
+
323
+ let controller = null;
324
+
325
+ const stream = new ReadableStream({
326
+ start(c) { controller = c; },
327
+ cancel() {
328
+ try { port.postMessage({ type: "flowersec-proxy:abort" }); } catch {}
329
+ try { port.close(); } catch {}
330
+ }
331
+ });
332
+
333
+ function pushInjectChunk(b) {
334
+ htmlBytes += b.length;
335
+ if (MAX_INJECT_HTML_BYTES > 0 && htmlBytes > MAX_INJECT_HTML_BYTES) {
336
+ const err = makeError(502, "html response too large to inject");
337
+ lastErrorStatus = err.status;
338
+ lastErrorMessage = err.message;
339
+ metaReject(err);
340
+ doneReject(err);
341
+ controller?.error(err);
342
+ try { port.close(); } catch {}
343
+ return false;
260
344
  }
261
- metaResolve(m);
262
- if (controller && !shouldInjectHTML) for (const q of queued) controller.enqueue(q);
263
- if (shouldInjectHTML) for (const q of queued) htmlChunks.push(q);
264
- queued.length = 0;
265
- return;
345
+ htmlChunks.push(b);
346
+ return true;
266
347
  }
267
- if (m.type === "flowersec-proxy:response_chunk") {
268
- const b = new Uint8Array(m.data);
269
- if (shouldInjectHTML) {
270
- htmlChunks.push(b);
348
+
349
+ port.onmessage = (ev) => {
350
+ const m = ev.data;
351
+ if (!m || typeof m.type !== "string") return;
352
+ if (m.type === "flowersec-proxy:response_meta") {
353
+ if (injectAllowed) {
354
+ const ct = String((m.headers || []).find((h) => (h.name || "").toLowerCase() === "content-type")?.value || "");
355
+ shouldInjectHTML = ct.toLowerCase().includes("text/html");
356
+
357
+ if (shouldInjectHTML && MAX_INJECT_HTML_BYTES > 0) {
358
+ const cl = Number(
359
+ String((m.headers || []).find((h) => (h.name || "").toLowerCase() === "content-length")?.value || "")
360
+ );
361
+ if (Number.isFinite(cl) && cl > MAX_INJECT_HTML_BYTES) {
362
+ const err = makeError(502, "html response too large to inject");
363
+ lastErrorStatus = err.status;
364
+ lastErrorMessage = err.message;
365
+ metaReject(err);
366
+ doneReject(err);
367
+ controller?.error(err);
368
+ try { port.close(); } catch {}
369
+ return;
370
+ }
371
+ }
372
+ }
373
+ metaResolve(m);
374
+ if (controller && !shouldInjectHTML) for (const q of queued) controller.enqueue(q);
375
+ if (shouldInjectHTML) for (const q of queued) if (!pushInjectChunk(q)) return;
376
+ queued.length = 0;
271
377
  return;
272
378
  }
273
- if (controller) controller.enqueue(b); else queued.push(b);
274
- return;
275
- }
276
- if (m.type === "flowersec-proxy:response_end") {
277
- if (shouldInjectHTML) {
278
- doneResolve(htmlChunks);
379
+ if (m.type === "flowersec-proxy:response_chunk") {
380
+ const b = new Uint8Array(m.data);
381
+ if (shouldInjectHTML) {
382
+ pushInjectChunk(b);
383
+ return;
384
+ }
385
+ if (controller) controller.enqueue(b); else queued.push(b);
279
386
  return;
280
387
  }
281
- controller?.close();
282
- return;
283
- }
284
- if (m.type === "flowersec-proxy:response_error") {
285
- const err = new Error(m.message || "proxy error");
286
- metaReject(err);
287
- doneReject(err);
288
- controller?.error(err);
289
- try { port.close(); } catch {}
290
- return;
388
+ if (m.type === "flowersec-proxy:response_end") {
389
+ if (shouldInjectHTML) {
390
+ doneResolve(htmlChunks);
391
+ return;
392
+ }
393
+ controller?.close();
394
+ return;
395
+ }
396
+ if (m.type === "flowersec-proxy:response_error") {
397
+ const status = typeof m.status === "number" && Number.isFinite(m.status) ? Math.floor(m.status) : 502;
398
+ lastErrorStatus = status > 0 ? status : 502;
399
+ lastErrorMessage = String(m.message || "proxy error");
400
+ const err = makeError(lastErrorStatus, lastErrorMessage);
401
+ metaReject(err);
402
+ doneReject(err);
403
+ controller?.error(err);
404
+ try { port.close(); } catch {}
405
+ return;
406
+ }
407
+ };
408
+
409
+ let path = url.pathname + url.search;
410
+ if (PROXY_PATH_PREFIX && STRIP_PROXY_PATH_PREFIX) {
411
+ let rest = url.pathname.slice(PROXY_PATH_PREFIX.length);
412
+ if (rest.startsWith("/")) rest = rest.slice(1);
413
+ path = "/" + rest + url.search;
291
414
  }
292
- };
293
415
 
294
- let path = url.pathname + url.search;
295
- if (PROXY_PATH_PREFIX && STRIP_PROXY_PATH_PREFIX) {
296
- let rest = url.pathname.slice(PROXY_PATH_PREFIX.length);
297
- if (rest.startsWith("/")) rest = rest.slice(1);
298
- path = "/" + rest + url.search;
299
- }
416
+ runtime.postMessage({
417
+ type: "flowersec-proxy:fetch",
418
+ req: { id, method: req.method, path, headers: headersToPairs(req.headers), body }
419
+ }, [port2]);
420
+
421
+ const meta = await metaPromise;
422
+ const headers = new Headers();
423
+ for (const h of (meta.headers || [])) {
424
+ const name = String(h.name || "");
425
+ const lower = name.toLowerCase();
426
+ if (shouldInjectHTML && INJECT_STRIP_VALIDATOR_HEADERS && INJECT_STRIP_HEADER_NAMES.has(lower)) continue;
427
+ headers.append(name, String(h.value || ""));
428
+ }
300
429
 
301
- runtime.postMessage({
302
- type: "flowersec-proxy:fetch",
303
- req: { id, method: req.method, path, headers: headersToPairs(req.headers), body }
304
- }, [port2]);
305
-
306
- const meta = await metaPromise;
307
- const headers = new Headers();
308
- for (const h of (meta.headers || [])) {
309
- const name = String(h.name || "");
310
- const lower = name.toLowerCase();
311
- if (shouldInjectHTML && INJECT_STRIP_VALIDATOR_HEADERS && INJECT_STRIP_HEADER_NAMES.has(lower)) continue;
312
- headers.append(name, String(h.value || ""));
313
- }
430
+ if (shouldInjectHTML && INJECT_SET_NO_STORE && !headers.has("Cache-Control")) {
431
+ headers.set("Cache-Control", "no-store");
432
+ }
314
433
 
315
- if (shouldInjectHTML && INJECT_SET_NO_STORE && !headers.has("Cache-Control")) {
316
- headers.set("Cache-Control", "no-store");
317
- }
434
+ if (shouldInjectHTML) {
435
+ const chunks = await donePromise;
436
+ const raw = concatChunks(chunks);
437
+ const html = new TextDecoder().decode(raw);
438
+ const injected = injectBootstrap(html);
439
+ return new Response(new TextEncoder().encode(injected), { status: meta.status || 502, headers });
440
+ }
318
441
 
319
- if (shouldInjectHTML) {
320
- const chunks = await donePromise;
321
- const raw = concatChunks(chunks);
322
- const html = new TextDecoder().decode(raw);
323
- const injected = injectBootstrap(html);
324
- return new Response(new TextEncoder().encode(injected), { status: meta.status || 502, headers });
442
+ return new Response(stream, { status: meta.status || 502, headers });
443
+ } catch (err) {
444
+ const status = getErrorStatus(err, lastErrorStatus);
445
+ const msg = err instanceof Error ? err.message : String(err);
446
+ return new Response(msg || lastErrorMessage || "flowersec-proxy error", { status });
325
447
  }
326
-
327
- return new Response(stream, { status: meta.status || 502, headers });
328
448
  }
329
449
  `;
330
450
  }
@@ -119,14 +119,18 @@ export function installWebSocketPatch(opts) {
119
119
  removeEventListener(type, listener) {
120
120
  this.listeners.off(type, listener);
121
121
  }
122
+ queueWriteFrame(stream, op, payload) {
123
+ this.writeChain = this.writeChain
124
+ .then(() => writeWSFrame(stream, op, payload, maxWsFrameBytes))
125
+ .catch((e) => this.fail(e));
126
+ }
122
127
  send(data) {
123
128
  if (this.readyState !== PatchedWebSocket.OPEN || this.stream == null) {
124
129
  throw new Error("WebSocket is not open");
125
130
  }
131
+ const s = this.stream;
126
132
  const sendBytes = (op, payload) => {
127
- this.writeChain = this.writeChain
128
- .then(() => writeWSFrame(this.stream, op, payload, maxWsFrameBytes))
129
- .catch((e) => this.fail(e));
133
+ this.queueWriteFrame(s, op, payload);
130
134
  };
131
135
  if (typeof data === "string") {
132
136
  sendBytes(1, te.encode(data));
@@ -216,7 +220,8 @@ export function installWebSocketPatch(opts) {
216
220
  const { op, payload } = await readWSFrame(reader, maxWsFrameBytes);
217
221
  if (op === 9) {
218
222
  // Ping -> Pong (not exposed to WebSocket JS API).
219
- await writeWSFrame(stream, 10, payload, maxWsFrameBytes);
223
+ // Must be serialized with user writes, otherwise concurrent writes can corrupt framing.
224
+ this.queueWriteFrame(stream, 10, payload);
220
225
  continue;
221
226
  }
222
227
  if (op === 10)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {