@elfa-ai/mcp 4.1.0 → 4.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/CHANGELOG.md +18 -0
- package/README.md +37 -2
- package/dist/index.js +186 -7
- package/manifest.json +1 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.2.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **OAuth sign-in for hosted deployments** (`ELFA_MCP_AUTH=oauth`, HTTP transport only). Clients such as ChatGPT, Claude and Codex can connect with a browser sign-in instead of an API key. The server acts as an OAuth resource server under the MCP authorization spec:
|
|
8
|
+
- It serves RFC 9728 protected-resource metadata at `/.well-known/oauth-protected-resource/mcp` (and at the root path).
|
|
9
|
+
- A request with no credential gets `401` with a `WWW-Authenticate: Bearer resource_metadata=…` challenge.
|
|
10
|
+
- Each bearer token is checked against the authorization server's introspection endpoint, which returns the Elfa API key the request runs as. The token itself is never forwarded to the Elfa API.
|
|
11
|
+
- The token must be issued for this server: its audience has to match `ELFA_MCP_RESOURCE_URL`.
|
|
12
|
+
- Valid answers are cached for up to 60 seconds.
|
|
13
|
+
- An unreachable authorization server returns `503`, not `401`, so clients don't restart sign-in because of an outage.
|
|
14
|
+
- An `x-elfa-api-key` header still works in OAuth mode, so API-key users of the same deployment are unaffected.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `getMarketEvents-v2` is no longer listed as unexposed. The Elfa API dropped it from the published spec, so there is nothing left to account for.
|
|
19
|
+
- In OAuth mode `ELFA_API_KEY` is ignored. Every request brings its own credential, and an environment key would otherwise be handed to any caller that sends none. API-key mode keeps its environment fallback.
|
|
20
|
+
|
|
3
21
|
## 4.1.0
|
|
4
22
|
|
|
5
23
|
### Security
|
package/README.md
CHANGED
|
@@ -79,7 +79,6 @@ Some MCP clients apply their own timeout, often around 60 seconds. `narratives`
|
|
|
79
79
|
|
|
80
80
|
Not exposed as tools:
|
|
81
81
|
|
|
82
|
-
- `getMarketEvents-v2` — Available only to select Enterprise customers, and the published operation takes no parameters. Contact sales@elfa.ai for access.
|
|
83
82
|
- `chat-stream-v2` — A tool call returns one result, so streaming adds nothing. market_chat covers the same analysis.
|
|
84
83
|
- `auto-stream-queries-v2` — Long lived streams have no tool equivalent. Poll with auto_query.
|
|
85
84
|
- `auto-stream-query-v2` — Long lived streams have no tool equivalent. Poll with auto_query.
|
|
@@ -123,7 +122,7 @@ The same server runs over Streamable HTTP for hosted deployments:
|
|
|
123
122
|
ELFA_MCP_TRANSPORT=http ELFA_MCP_PORT=3000 npx -y @elfa-ai/mcp
|
|
124
123
|
```
|
|
125
124
|
|
|
126
|
-
It is stateless — no sessions, one server instance per request, safe behind a load balancer. Credentials come from the `x-elfa-api-key` request header, falling back to the environment.
|
|
125
|
+
It is stateless — no sessions, one server instance per request, safe behind a load balancer. Credentials come from the `x-elfa-api-key` request header, falling back to the environment, or from an OAuth sign-in (see below).
|
|
127
126
|
|
|
128
127
|
DNS rebinding protection is on by default. The server accepts only the loopback names it binds — `localhost:PORT` and `127.0.0.1:PORT` — which covers the local run above and nothing else. Any deployment that answers on a different `Host` must list the values it serves:
|
|
129
128
|
|
|
@@ -143,6 +142,42 @@ That includes a public domain, a reverse proxy, and a container that maps the po
|
|
|
143
142
|
|
|
144
143
|
Set `ELFA_MCP_ALLOWED_ORIGINS` as well when browsers call the server directly. It complements the host allowlist rather than replacing it: a rebound request is same origin, so it carries no `Origin` header for that list to check, and the `Host` header is the only one still naming the attacker's domain.
|
|
145
144
|
|
|
145
|
+
### OAuth sign-in
|
|
146
|
+
|
|
147
|
+
A hosted server can let clients sign in through a browser instead of sending an API key. Set `ELFA_MCP_AUTH=oauth` and the server becomes an OAuth resource server under the MCP authorization spec:
|
|
148
|
+
|
|
149
|
+
1. A request with no credential gets `401` and a `WWW-Authenticate` challenge.
|
|
150
|
+
2. The challenge points the client to the protected-resource metadata at `/.well-known/oauth-protected-resource/mcp`.
|
|
151
|
+
3. That metadata names the authorization server, where the user signs in.
|
|
152
|
+
4. The client then sends `Authorization: Bearer <token>`.
|
|
153
|
+
5. The server checks the token against the authorization server's introspection endpoint. The endpoint answers with the Elfa API key the request runs as, so the token never reaches the Elfa API.
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
ELFA_MCP_TRANSPORT=http \
|
|
157
|
+
ELFA_MCP_AUTH=oauth \
|
|
158
|
+
ELFA_MCP_RESOURCE_URL=https://mcp.example.com/mcp \
|
|
159
|
+
ELFA_OAUTH_ISSUER=https://auth.example.com \
|
|
160
|
+
ELFA_OAUTH_INTROSPECTION_URL=https://auth.example.com/introspect \
|
|
161
|
+
ELFA_OAUTH_INTROSPECTION_TOKEN=... \
|
|
162
|
+
ELFA_MCP_ALLOWED_HOSTS=mcp.example.com \
|
|
163
|
+
npx -y @elfa-ai/mcp
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
| Variable | Required in OAuth mode | Purpose |
|
|
167
|
+
| --- | --- | --- |
|
|
168
|
+
| `ELFA_MCP_AUTH` | yes | `oauth` to enable, default `apikey` |
|
|
169
|
+
| `ELFA_MCP_RESOURCE_URL` | yes | Canonical URL of this endpoint. Tokens must be issued for exactly this value |
|
|
170
|
+
| `ELFA_OAUTH_ISSUER` | yes | Authorization server listed in the metadata |
|
|
171
|
+
| `ELFA_OAUTH_INTROSPECTION_URL` | yes | Where tokens are checked |
|
|
172
|
+
| `ELFA_OAUTH_INTROSPECTION_TOKEN` | yes | Bearer sent to the introspection endpoint |
|
|
173
|
+
| `ELFA_OAUTH_SCOPES` | no | Comma separated scopes to advertise, default `elfa` |
|
|
174
|
+
|
|
175
|
+
Notes:
|
|
176
|
+
|
|
177
|
+
- An `x-elfa-api-key` header still works in OAuth mode.
|
|
178
|
+
- `ELFA_API_KEY` is ignored in OAuth mode, so a caller with no credential never runs as the server's own key.
|
|
179
|
+
- Valid tokens are cached for up to a minute, which bounds how long a revoked token keeps working.
|
|
180
|
+
|
|
146
181
|
## Safety
|
|
147
182
|
|
|
148
183
|
`api_status` is the fastest way to tell an auth problem from a credit problem.
|
package/dist/index.js
CHANGED
|
@@ -112,15 +112,44 @@ function list(value) {
|
|
|
112
112
|
if (!value) return [];
|
|
113
113
|
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
114
114
|
}
|
|
115
|
+
var ConfigError = class extends Error {
|
|
116
|
+
};
|
|
117
|
+
function oauthConfig(env) {
|
|
118
|
+
const required = {
|
|
119
|
+
ELFA_MCP_RESOURCE_URL: env.ELFA_MCP_RESOURCE_URL,
|
|
120
|
+
ELFA_OAUTH_ISSUER: env.ELFA_OAUTH_ISSUER,
|
|
121
|
+
ELFA_OAUTH_INTROSPECTION_URL: env.ELFA_OAUTH_INTROSPECTION_URL,
|
|
122
|
+
ELFA_OAUTH_INTROSPECTION_TOKEN: env.ELFA_OAUTH_INTROSPECTION_TOKEN
|
|
123
|
+
};
|
|
124
|
+
const missing = Object.entries(required).filter(([, value]) => !value).map(([name]) => name);
|
|
125
|
+
if (missing.length > 0) {
|
|
126
|
+
throw new ConfigError(
|
|
127
|
+
`ELFA_MCP_AUTH=oauth requires ${missing.join(", ")}.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
resource: required.ELFA_MCP_RESOURCE_URL,
|
|
132
|
+
issuer: required.ELFA_OAUTH_ISSUER,
|
|
133
|
+
introspectionUrl: required.ELFA_OAUTH_INTROSPECTION_URL,
|
|
134
|
+
introspectionToken: required.ELFA_OAUTH_INTROSPECTION_TOKEN,
|
|
135
|
+
scopes: list(env.ELFA_OAUTH_SCOPES).length > 0 ? list(env.ELFA_OAUTH_SCOPES) : ["elfa"]
|
|
136
|
+
};
|
|
137
|
+
}
|
|
115
138
|
function loadConfig(env = process.env) {
|
|
116
139
|
const transport = env.ELFA_MCP_TRANSPORT === "http" ? "http" : "stdio";
|
|
140
|
+
const auth = env.ELFA_MCP_AUTH === "oauth" ? "oauth" : "apikey";
|
|
141
|
+
if (auth === "oauth" && transport !== "http") {
|
|
142
|
+
throw new ConfigError("ELFA_MCP_AUTH=oauth needs ELFA_MCP_TRANSPORT=http.");
|
|
143
|
+
}
|
|
117
144
|
return {
|
|
118
145
|
transport,
|
|
119
146
|
port: num(env.ELFA_MCP_PORT, DEFAULT_PORT),
|
|
120
147
|
host: env.ELFA_MCP_HOST || DEFAULT_HOST,
|
|
121
148
|
allowedOrigins: list(env.ELFA_MCP_ALLOWED_ORIGINS),
|
|
122
149
|
allowedHosts: list(env.ELFA_MCP_ALLOWED_HOSTS),
|
|
123
|
-
|
|
150
|
+
// In oauth mode every request brings its own credential. An environment
|
|
151
|
+
// key would be handed to any caller that sends none, so it is ignored.
|
|
152
|
+
apiKey: auth === "oauth" ? void 0 : env.ELFA_API_KEY || void 0,
|
|
124
153
|
baseUrl: env.ELFA_BASE_URL || void 0,
|
|
125
154
|
extraHeaders: headers(env.ELFA_EXTRA_HEADERS),
|
|
126
155
|
timeout: num(env.ELFA_TIMEOUT, DEFAULT_TIMEOUT),
|
|
@@ -128,7 +157,9 @@ function loadConfig(env = process.env) {
|
|
|
128
157
|
maxResponseChars: num(
|
|
129
158
|
env.ELFA_MCP_MAX_RESPONSE_CHARS,
|
|
130
159
|
DEFAULT_MAX_RESPONSE_CHARS
|
|
131
|
-
)
|
|
160
|
+
),
|
|
161
|
+
auth,
|
|
162
|
+
oauth: auth === "oauth" ? oauthConfig(env) : void 0
|
|
132
163
|
};
|
|
133
164
|
}
|
|
134
165
|
|
|
@@ -142,7 +173,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
142
173
|
// package.json
|
|
143
174
|
var package_default = {
|
|
144
175
|
name: "@elfa-ai/mcp",
|
|
145
|
-
version: "4.
|
|
176
|
+
version: "4.2.0",
|
|
146
177
|
description: "Model Context Protocol server for the Elfa API - crypto social intelligence and the Auto condition engine",
|
|
147
178
|
license: "MIT",
|
|
148
179
|
author: "Elfa AI",
|
|
@@ -1230,6 +1261,111 @@ function createServer(deps) {
|
|
|
1230
1261
|
return server;
|
|
1231
1262
|
}
|
|
1232
1263
|
|
|
1264
|
+
// src/oauth.ts
|
|
1265
|
+
import { createHash } from "crypto";
|
|
1266
|
+
var CACHE_TTL_MS = 6e4;
|
|
1267
|
+
var CACHE_MAX_ENTRIES = 1e4;
|
|
1268
|
+
var INTROSPECTION_TIMEOUT_MS = 5e3;
|
|
1269
|
+
var IntrospectionUnavailableError = class extends Error {
|
|
1270
|
+
};
|
|
1271
|
+
function protectedResourceMetadataPath(resource) {
|
|
1272
|
+
const { pathname } = new URL(resource);
|
|
1273
|
+
const suffix = pathname === "/" ? "" : pathname.replace(/\/+$/, "");
|
|
1274
|
+
return `/.well-known/oauth-protected-resource${suffix}`;
|
|
1275
|
+
}
|
|
1276
|
+
function protectedResourceMetadataUrl(resource) {
|
|
1277
|
+
const url = new URL(resource);
|
|
1278
|
+
return `${url.origin}${protectedResourceMetadataPath(resource)}`;
|
|
1279
|
+
}
|
|
1280
|
+
function protectedResourceMetadata(config) {
|
|
1281
|
+
return {
|
|
1282
|
+
resource: config.resource,
|
|
1283
|
+
authorization_servers: [config.issuer],
|
|
1284
|
+
scopes_supported: config.scopes,
|
|
1285
|
+
bearer_methods_supported: ["header"],
|
|
1286
|
+
resource_name: "Elfa",
|
|
1287
|
+
resource_documentation: "https://docs.elfa.ai/mcp"
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
function bearerChallenge(config, error) {
|
|
1291
|
+
const parts = [
|
|
1292
|
+
`resource_metadata="${protectedResourceMetadataUrl(config.resource)}"`,
|
|
1293
|
+
`scope="${config.scopes.join(" ")}"`
|
|
1294
|
+
];
|
|
1295
|
+
if (error) {
|
|
1296
|
+
parts.unshift(`error="${error.code}"`);
|
|
1297
|
+
parts.push(`error_description="${error.description.replace(/"/g, "'")}"`);
|
|
1298
|
+
}
|
|
1299
|
+
return `Bearer ${parts.join(", ")}`;
|
|
1300
|
+
}
|
|
1301
|
+
function bearerToken(authorization) {
|
|
1302
|
+
if (!authorization) return void 0;
|
|
1303
|
+
const match = /^Bearer\s+(\S+)\s*$/i.exec(authorization);
|
|
1304
|
+
return match?.[1];
|
|
1305
|
+
}
|
|
1306
|
+
var TokenVerifier = class {
|
|
1307
|
+
constructor(config, fetchImpl = fetch, now = Date.now) {
|
|
1308
|
+
this.config = config;
|
|
1309
|
+
this.fetchImpl = fetchImpl;
|
|
1310
|
+
this.now = now;
|
|
1311
|
+
}
|
|
1312
|
+
config;
|
|
1313
|
+
fetchImpl;
|
|
1314
|
+
now;
|
|
1315
|
+
cache = /* @__PURE__ */ new Map();
|
|
1316
|
+
/** The API key for a valid token, or undefined when the token is not valid. */
|
|
1317
|
+
async verify(token) {
|
|
1318
|
+
const cacheKey = createHash("sha256").update(token).digest("hex");
|
|
1319
|
+
const cached = this.cache.get(cacheKey);
|
|
1320
|
+
if (cached && cached.expiresAtMs > this.now()) return cached;
|
|
1321
|
+
if (cached) this.cache.delete(cacheKey);
|
|
1322
|
+
const body = await this.introspect(token);
|
|
1323
|
+
if (!body.active || typeof body.elfa_api_key !== "string") return void 0;
|
|
1324
|
+
const audiences = Array.isArray(body.aud) ? body.aud : [body.aud];
|
|
1325
|
+
if (!audiences.includes(this.config.resource)) return void 0;
|
|
1326
|
+
const tokenExpiryMs = typeof body.exp === "number" ? body.exp * 1e3 : this.now() + CACHE_TTL_MS;
|
|
1327
|
+
if (tokenExpiryMs <= this.now()) return void 0;
|
|
1328
|
+
const verified = {
|
|
1329
|
+
apiKey: body.elfa_api_key,
|
|
1330
|
+
expiresAtMs: Math.min(tokenExpiryMs, this.now() + CACHE_TTL_MS)
|
|
1331
|
+
};
|
|
1332
|
+
if (this.cache.size >= CACHE_MAX_ENTRIES) {
|
|
1333
|
+
const oldest = this.cache.keys().next().value;
|
|
1334
|
+
if (oldest !== void 0) this.cache.delete(oldest);
|
|
1335
|
+
}
|
|
1336
|
+
this.cache.set(cacheKey, verified);
|
|
1337
|
+
return verified;
|
|
1338
|
+
}
|
|
1339
|
+
async introspect(token) {
|
|
1340
|
+
let response;
|
|
1341
|
+
try {
|
|
1342
|
+
response = await this.fetchImpl(this.config.introspectionUrl, {
|
|
1343
|
+
method: "POST",
|
|
1344
|
+
headers: {
|
|
1345
|
+
authorization: `Bearer ${this.config.introspectionToken}`,
|
|
1346
|
+
"content-type": "application/json"
|
|
1347
|
+
},
|
|
1348
|
+
body: JSON.stringify({ token }),
|
|
1349
|
+
signal: AbortSignal.timeout(INTROSPECTION_TIMEOUT_MS)
|
|
1350
|
+
});
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
throw new IntrospectionUnavailableError(
|
|
1353
|
+
error instanceof Error ? error.message : String(error)
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
if (!response.ok) {
|
|
1357
|
+
throw new IntrospectionUnavailableError(
|
|
1358
|
+
`introspection returned HTTP ${response.status}`
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
try {
|
|
1362
|
+
return await response.json();
|
|
1363
|
+
} catch {
|
|
1364
|
+
throw new IntrospectionUnavailableError("introspection returned invalid JSON");
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1233
1369
|
// src/http.ts
|
|
1234
1370
|
var MCP_PATH = "/mcp";
|
|
1235
1371
|
function header(req, name) {
|
|
@@ -1251,8 +1387,10 @@ function defaultAllowedHosts(config) {
|
|
|
1251
1387
|
}
|
|
1252
1388
|
return [...hosts];
|
|
1253
1389
|
}
|
|
1254
|
-
function createHttpApp(config) {
|
|
1390
|
+
function createHttpApp(config, options = {}) {
|
|
1255
1391
|
const app = express();
|
|
1392
|
+
const oauth = config.oauth;
|
|
1393
|
+
const verifier = options.verifier ?? (oauth ? new TokenVerifier(oauth) : void 0);
|
|
1256
1394
|
app.disable("x-powered-by");
|
|
1257
1395
|
app.use(express.json({ limit: "4mb" }));
|
|
1258
1396
|
app.use(
|
|
@@ -1267,6 +1405,47 @@ function createHttpApp(config) {
|
|
|
1267
1405
|
app.get("/healthz", (_req, res) => {
|
|
1268
1406
|
res.json({ ok: true });
|
|
1269
1407
|
});
|
|
1408
|
+
if (oauth) {
|
|
1409
|
+
const sendMetadata = (_req, res) => {
|
|
1410
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1411
|
+
res.setHeader("Cache-Control", "public, max-age=300");
|
|
1412
|
+
res.json(protectedResourceMetadata(oauth));
|
|
1413
|
+
};
|
|
1414
|
+
const metadataPath = protectedResourceMetadataPath(oauth.resource);
|
|
1415
|
+
app.get(metadataPath, sendMetadata);
|
|
1416
|
+
if (metadataPath !== "/.well-known/oauth-protected-resource") {
|
|
1417
|
+
app.get("/.well-known/oauth-protected-resource", sendMetadata);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
async function resolveApiKey(req, res) {
|
|
1421
|
+
const headerKey = header(req, "x-elfa-api-key");
|
|
1422
|
+
if (headerKey || !oauth || !verifier) return headerKey ?? null;
|
|
1423
|
+
const token = bearerToken(header(req, "authorization"));
|
|
1424
|
+
if (!token) {
|
|
1425
|
+
res.setHeader("WWW-Authenticate", bearerChallenge(oauth));
|
|
1426
|
+
jsonRpcError(res, 401, "Authorization required. Sign in to Elfa to use this server.");
|
|
1427
|
+
return void 0;
|
|
1428
|
+
}
|
|
1429
|
+
try {
|
|
1430
|
+
const verified = await verifier.verify(token);
|
|
1431
|
+
if (verified) return verified.apiKey;
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
if (error instanceof IntrospectionUnavailableError) {
|
|
1434
|
+
jsonRpcError(res, 503, "Authorization service unavailable, try again shortly.");
|
|
1435
|
+
return void 0;
|
|
1436
|
+
}
|
|
1437
|
+
throw error;
|
|
1438
|
+
}
|
|
1439
|
+
res.setHeader(
|
|
1440
|
+
"WWW-Authenticate",
|
|
1441
|
+
bearerChallenge(oauth, {
|
|
1442
|
+
code: "invalid_token",
|
|
1443
|
+
description: "The access token is invalid or expired"
|
|
1444
|
+
})
|
|
1445
|
+
);
|
|
1446
|
+
jsonRpcError(res, 401, "Invalid or expired access token.");
|
|
1447
|
+
return void 0;
|
|
1448
|
+
}
|
|
1270
1449
|
app.post(MCP_PATH, async (req, res) => {
|
|
1271
1450
|
const origin = header(req, "origin");
|
|
1272
1451
|
if (origin && config.allowedOrigins.length > 0 && !config.allowedOrigins.includes(origin)) {
|
|
@@ -1276,9 +1455,9 @@ function createHttpApp(config) {
|
|
|
1276
1455
|
let server;
|
|
1277
1456
|
let transport;
|
|
1278
1457
|
try {
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
});
|
|
1458
|
+
const apiKey = await resolveApiKey(req, res);
|
|
1459
|
+
if (apiKey === void 0) return;
|
|
1460
|
+
const deps = buildDeps(config, { apiKey: apiKey ?? void 0 });
|
|
1282
1461
|
server = createServer(deps);
|
|
1283
1462
|
transport = new StreamableHTTPServerTransport({
|
|
1284
1463
|
enableDnsRebindingProtection: true,
|
package/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"server": {
|
|
3
3
|
"name": "elfa",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.2.0",
|
|
5
5
|
"docs": "https://docs.elfa.ai",
|
|
6
6
|
"spec": "https://docs.elfa.ai/swagger/swagger.json",
|
|
7
7
|
"specScope": "/v2"
|
|
@@ -193,11 +193,6 @@
|
|
|
193
193
|
}
|
|
194
194
|
],
|
|
195
195
|
"unexposed": [
|
|
196
|
-
{
|
|
197
|
-
"operationId": "getMarketEvents-v2",
|
|
198
|
-
"reason": "restricted",
|
|
199
|
-
"note": "Available only to select Enterprise customers, and the published operation takes no parameters. Contact sales@elfa.ai for access."
|
|
200
|
-
},
|
|
201
196
|
{
|
|
202
197
|
"operationId": "chat-stream-v2",
|
|
203
198
|
"reason": "sse",
|
package/package.json
CHANGED