utn-mcp 0.7.0 → 0.8.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/README.md +199 -180
- package/build/aula-virtual/client/client.js +2 -2
- package/build/aula-virtual/client/link.js +18 -11
- package/build/aula-virtual/client/local-session.js +21 -13
- package/build/aula-virtual/client/moodle-api.js +6 -5
- package/build/aula-virtual/client/regionals.js +112 -0
- package/build/aula-virtual/client/sso-login.js +277 -27
- package/build/aula-virtual/client/stored-session.js +24 -20
- package/build/aula-virtual/format/actions.js +1 -2
- package/build/aula-virtual/format/files.js +30 -11
- package/build/aula-virtual/login.js +25 -10
- package/build/aula-virtual/tools.js +11 -5
- package/build/http.js +57 -9
- package/build/index.js +12 -13
- package/build/oauth/access.js +3 -3
- package/build/oauth/admin.js +80 -13
- package/build/oauth/redirects.js +60 -0
- package/build/oauth/server.js +106 -37
- package/build/server.js +1 -1
- package/build/shared/input.js +60 -0
- package/build/shared/load-env.js +5 -0
- package/build/shared/pages.js +42 -37
- package/build/shared/paths.js +1 -1
- package/build/shared/store/sqlite-store.js +112 -40
- package/build/shared/uploads.js +9 -1
- package/package.json +58 -58
- package/build/aula-virtual/client/constants.js +0 -2
|
@@ -1,31 +1,34 @@
|
|
|
1
|
-
import { ask, askHidden } from "../shared/input.js";
|
|
1
|
+
import { ask, askChoice, askHidden } from "../shared/input.js";
|
|
2
2
|
import { logger } from "../shared/logger.js";
|
|
3
3
|
import { SqliteStore } from "../shared/store/sqlite-store.js";
|
|
4
4
|
import { saveCredentials } from "./client/credentials.js";
|
|
5
5
|
import { linkAulaAccount } from "./client/link.js";
|
|
6
6
|
import { LocalSession } from "./client/local-session.js";
|
|
7
|
+
import { availableRegionals, defaultRegional } from "./client/regionals.js";
|
|
7
8
|
/**
|
|
8
|
-
* `utn-mcp login`: pide usuario y contraseña una vez, los guarda en el keychain
|
|
9
|
+
* `utn-mcp login`: pide la regional, usuario y contraseña una vez, los guarda en el keychain
|
|
9
10
|
* y obtiene el primer token. Después el servidor renueva solo.
|
|
10
11
|
* Con `--http` guarda la sesión en el store y emite el token para el cliente MCP.
|
|
11
12
|
*/
|
|
12
13
|
export async function runLogin(http) {
|
|
13
|
-
const
|
|
14
|
+
const regional = await askRegional();
|
|
15
|
+
const prompt = regional.sso?.provider === "microsoft" ? "Mail de Office 365: " : "Usuario de la UTN: ";
|
|
16
|
+
const username = (await ask(prompt)).trim();
|
|
14
17
|
if (!username)
|
|
15
18
|
throw new Error("Falta el usuario.");
|
|
16
19
|
const password = await askHidden("Contraseña: ");
|
|
17
|
-
const credentials = { username, password };
|
|
20
|
+
const credentials = { username, password, regional: regional.id };
|
|
18
21
|
let accessToken = null;
|
|
19
22
|
if (http)
|
|
20
|
-
accessToken = await loginToStore(credentials);
|
|
23
|
+
accessToken = await loginToStore(regional, credentials);
|
|
21
24
|
else
|
|
22
|
-
await new LocalSession().login(credentials);
|
|
23
|
-
// Se guardan recién después de comprobar que el
|
|
25
|
+
await new LocalSession().login(regional, credentials);
|
|
26
|
+
// Se guardan recién después de comprobar que el aula las aceptó.
|
|
24
27
|
if (await saveCredentials(credentials)) {
|
|
25
28
|
logger.info("Credenciales guardadas en el keychain del sistema.");
|
|
26
29
|
}
|
|
27
30
|
else {
|
|
28
|
-
logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER y
|
|
31
|
+
logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER, UTN_PASS y UTN_REGIONAL.");
|
|
29
32
|
}
|
|
30
33
|
if (accessToken) {
|
|
31
34
|
logger.info("Listo. Configurá tu cliente MCP con este header; no se vuelve a mostrar:\n\n" +
|
|
@@ -35,9 +38,21 @@ export async function runLogin(http) {
|
|
|
35
38
|
logger.info("Listo: ya podés usar el servidor MCP.");
|
|
36
39
|
}
|
|
37
40
|
}
|
|
41
|
+
const SUPPORT_LABEL = {
|
|
42
|
+
verified: "",
|
|
43
|
+
candidate: " · sin probar todavía",
|
|
44
|
+
unsupported: " · todavía sin soporte",
|
|
45
|
+
};
|
|
46
|
+
/** Arranca marcada la de UTN_REGIONAL (o FRBA). */
|
|
47
|
+
async function askRegional() {
|
|
48
|
+
const regionals = availableRegionals();
|
|
49
|
+
const initial = regionals.findIndex((r) => r.id === defaultRegional().id);
|
|
50
|
+
const labels = regionals.map((r) => r.name + SUPPORT_LABEL[r.support]);
|
|
51
|
+
return regionals[await askChoice("Regional:", labels, Math.max(initial, 0))];
|
|
52
|
+
}
|
|
38
53
|
/** Crea el usuario si no existe, guarda su sesión del aula y le emite un token que no vence. */
|
|
39
|
-
async function loginToStore(credentials) {
|
|
54
|
+
async function loginToStore(regional, credentials) {
|
|
40
55
|
const store = await SqliteStore.open();
|
|
41
|
-
const { userId } = await linkAulaAccount(store, credentials);
|
|
56
|
+
const { userId } = await linkAulaAccount(store, regional, credentials);
|
|
42
57
|
return store.issueToken(userId, { kind: "access", clientId: "local" });
|
|
43
58
|
}
|
|
@@ -17,12 +17,16 @@ const failure = (e) => ({
|
|
|
17
17
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
|
|
18
18
|
export function registerAulaVirtualTools(server, session, files) {
|
|
19
19
|
server.registerTool("aula_get_site_info", {
|
|
20
|
-
description: "Datos del usuario logueado
|
|
20
|
+
description: "Datos del usuario logueado y del sitio Moodle de su aula virtual. `regional` es la regional de la UTN " +
|
|
21
|
+
"de esa aula (por ejemplo, \"Tucumán (FRT)\"): usala para hablarle de su facultad.",
|
|
21
22
|
annotations: READ_ONLY,
|
|
22
23
|
}, async () => {
|
|
23
24
|
try {
|
|
24
|
-
const { sitename, fullname, username, userid, release } = await
|
|
25
|
-
|
|
25
|
+
const [{ sitename, fullname, username, userid, release }, regional] = await Promise.all([
|
|
26
|
+
getSiteInfo(session),
|
|
27
|
+
session.getRegional(),
|
|
28
|
+
]);
|
|
29
|
+
return json({ regional: regional.name, sitename, fullname, username, userid, release });
|
|
26
30
|
}
|
|
27
31
|
catch (e) {
|
|
28
32
|
return failure(e);
|
|
@@ -271,7 +275,8 @@ export function registerAulaVirtualTools(server, session, files) {
|
|
|
271
275
|
'`-F "archivos=@..."` por archivo, todos en el mismo comando. Responde JSON con lo recibido.\n' +
|
|
272
276
|
"- Si no, pasale el `url` al usuario: se abre en el navegador, también en el celu, y elige " +
|
|
273
277
|
"los archivos ahí. Esperá a que te diga que los subió.\n\n" +
|
|
274
|
-
"Subir al link no entrega nada
|
|
278
|
+
"Subir al link no entrega nada. Cada link acepta una sola subida: si hay que cambiar los " +
|
|
279
|
+
"archivos, pedí otro link. " +
|
|
275
280
|
"`acepta` trae los límites de la tarea.",
|
|
276
281
|
inputSchema: z.object({
|
|
277
282
|
direccion: z
|
|
@@ -299,7 +304,8 @@ export function registerAulaVirtualTools(server, session, files) {
|
|
|
299
304
|
'Ej: ["C:\\Users\\ana\\Documents\\TP3.pdf"].'
|
|
300
305
|
: "Lista final de los archivos que ya están en la entrega, por su `url` de aula_open; los " +
|
|
301
306
|
"de `subida` se suman. Lista vacía: quita los que estaban. " +
|
|
302
|
-
'Ej: ["https
|
|
307
|
+
'Ej: ["https://<aula>/pluginfile.php/123/assignsubmission_file/submission_files/456/TP3.pdf"], ' +
|
|
308
|
+
"con el dominio del aula tal como viene en la `url`."),
|
|
303
309
|
texto: z
|
|
304
310
|
.string()
|
|
305
311
|
.optional()
|
package/build/http.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Readable } from "node:stream";
|
|
|
3
3
|
import { bearerAuthChallengeResponse, createMcpHandler, OAuthError, OAuthErrorCode, verifyBearerToken, } from "@modelcontextprotocol/server";
|
|
4
4
|
import { hostHeaderValidation, originValidation, toNodeHandler } from "@modelcontextprotocol/node";
|
|
5
5
|
import { mountAdmin } from "./oauth/admin.js";
|
|
6
|
-
import { mountOAuth } from "./oauth/server.js";
|
|
6
|
+
import { cors, mountOAuth } from "./oauth/server.js";
|
|
7
7
|
import { logger } from "./shared/logger.js";
|
|
8
8
|
import { escapeHtml, page } from "./shared/pages.js";
|
|
9
9
|
import { MAX_UPLOAD_BYTES, formatSize } from "./shared/uploads.js";
|
|
@@ -43,12 +43,14 @@ function storeVerifier(store) {
|
|
|
43
43
|
const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"];
|
|
44
44
|
/** El factory recibe en `ctx.authInfo` el token ya validado, con el usuario; de ahí arma su sesión. */
|
|
45
45
|
export function startHttpServer(factory, options, store, inbox) {
|
|
46
|
-
const { host, port, publicUrl, access, adminSecret } = options;
|
|
46
|
+
const { host, port, publicUrl, access, redirects, adminSecret } = options;
|
|
47
47
|
const handler = createMcpHandler(factory, { onerror: (e) => logger.error(e.message) });
|
|
48
48
|
const verifier = storeVerifier(store);
|
|
49
49
|
const app = express();
|
|
50
50
|
// Detrás de un reverse proxy en la misma máquina: la IP del cliente (para el rate limit) viene en X-Forwarded-For.
|
|
51
51
|
app.set("trust proxy", "loopback");
|
|
52
|
+
app.disable("x-powered-by");
|
|
53
|
+
app.use(securityHeaders(publicUrl?.startsWith("https://") ?? false));
|
|
52
54
|
// Contra DNS rebinding: solo se aceptan los hosts de esta máquina y el de la URL pública.
|
|
53
55
|
const allowed = publicUrl ? [...LOOPBACK, new URL(publicUrl).hostname] : LOOPBACK;
|
|
54
56
|
if (!publicUrl && !LOOPBACK.includes(host)) {
|
|
@@ -62,6 +64,7 @@ export function startHttpServer(factory, options, store, inbox) {
|
|
|
62
64
|
store,
|
|
63
65
|
access,
|
|
64
66
|
sameOrigin,
|
|
67
|
+
redirects,
|
|
65
68
|
});
|
|
66
69
|
if (adminSecret)
|
|
67
70
|
mountAdmin(app, store, adminSecret);
|
|
@@ -79,13 +82,20 @@ export function startHttpServer(factory, options, store, inbox) {
|
|
|
79
82
|
res.send(await challenge.text());
|
|
80
83
|
}
|
|
81
84
|
};
|
|
85
|
+
// Publicado, /mcp acepta cualquier origen: se autentica con el bearer, que el navegador no manda solo
|
|
86
|
+
// (no hay cookies), así que una página ajena no lo puede usar en nombre de nadie. Hay clientes que lo
|
|
87
|
+
// llaman desde su web, como Grok. En local sigue el chequeo de Origin, contra DNS rebinding.
|
|
88
|
+
const mcpOrigin = publicUrl ? cors : sameOrigin;
|
|
89
|
+
app.options("/mcp", mcpOrigin, (_req, res) => void res.sendStatus(204));
|
|
82
90
|
// Sin express.json(): el handler lee el body por su cuenta.
|
|
83
|
-
app.all("/mcp",
|
|
91
|
+
app.all("/mcp", mcpOrigin, requireToken, toNodeHandler(handler));
|
|
84
92
|
// Sin bearer: el id del link es el secreto, y lo abre un navegador o un `curl` que no lo tienen.
|
|
85
93
|
app.get("/subir/:id", sameOrigin, (req, res) => {
|
|
86
94
|
const upload = inbox.get(String(req.params.id));
|
|
87
95
|
if (!upload)
|
|
88
96
|
return void res.status(404).type("html").send(page("Link vencido", EXPIRED));
|
|
97
|
+
if (upload.files)
|
|
98
|
+
return void res.status(409).type("html").send(page("Link ya usado", USED));
|
|
89
99
|
res.type("html").send(page(upload.title, uploadForm(upload.limits)));
|
|
90
100
|
});
|
|
91
101
|
app.post("/subir/:id", sameOrigin, (req, res) => receiveUpload(inbox, req, res));
|
|
@@ -102,17 +112,21 @@ async function receiveUpload(inbox, req, res) {
|
|
|
102
112
|
? res.status(status).type("html").send(page("No se pudo subir", `<p>${escapeHtml(message)}</p>`))
|
|
103
113
|
: res.status(status).json({ error: message });
|
|
104
114
|
const id = String(req.params.id);
|
|
105
|
-
|
|
115
|
+
const upload = inbox.get(id);
|
|
116
|
+
if (!upload)
|
|
106
117
|
return void fail(404, "El link de subida venció o no existe.");
|
|
107
|
-
if (
|
|
108
|
-
return void fail(
|
|
109
|
-
}
|
|
118
|
+
if (upload.files)
|
|
119
|
+
return void fail(409, "Este link ya se usó. Pedile al agente uno nuevo.");
|
|
120
|
+
const tooBig = `La subida pasa el máximo de ${formatSize(MAX_UPLOAD_BYTES)}.`;
|
|
121
|
+
if (Number(req.headers["content-length"]) > MAX_UPLOAD_BYTES)
|
|
122
|
+
return void fail(413, tooBig);
|
|
123
|
+
const body = limitBytes(Readable.toWeb(req), MAX_UPLOAD_BYTES);
|
|
110
124
|
try {
|
|
111
125
|
// Request de la API web: parsea multipart sin sumar dependencias.
|
|
112
126
|
const request = new Request(`http://localhost${req.url}`, {
|
|
113
127
|
method: "POST",
|
|
114
128
|
headers: req.headers,
|
|
115
|
-
body:
|
|
129
|
+
body: body.stream,
|
|
116
130
|
duplex: "half",
|
|
117
131
|
});
|
|
118
132
|
const form = await request.formData();
|
|
@@ -131,10 +145,44 @@ async function receiveUpload(inbox, req, res) {
|
|
|
131
145
|
}
|
|
132
146
|
}
|
|
133
147
|
catch (e) {
|
|
134
|
-
|
|
148
|
+
if (body.exceeded()) {
|
|
149
|
+
fail(413, tooBig);
|
|
150
|
+
req.destroy();
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
fail(400, e.message);
|
|
154
|
+
}
|
|
135
155
|
}
|
|
136
156
|
}
|
|
157
|
+
function limitBytes(source, maxBytes) {
|
|
158
|
+
let total = 0;
|
|
159
|
+
const stream = source.pipeThrough(new TransformStream({
|
|
160
|
+
transform(chunk, controller) {
|
|
161
|
+
total += chunk.length;
|
|
162
|
+
if (total > maxBytes)
|
|
163
|
+
controller.error(new Error("La subida es demasiado grande."));
|
|
164
|
+
else
|
|
165
|
+
controller.enqueue(chunk);
|
|
166
|
+
},
|
|
167
|
+
}));
|
|
168
|
+
return { stream, exceeded: () => total > maxBytes };
|
|
169
|
+
}
|
|
170
|
+
function securityHeaders(https) {
|
|
171
|
+
return (_req, res, next) => {
|
|
172
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'");
|
|
173
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
174
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
175
|
+
// `same-origin` y no `no-referrer`: con `no-referrer` el navegador manda `Origin: null` en los POST de
|
|
176
|
+
// formularios, y el chequeo de Origin rechaza el login.
|
|
177
|
+
res.setHeader("Referrer-Policy", "same-origin");
|
|
178
|
+
// Un año, en segundos.
|
|
179
|
+
if (https)
|
|
180
|
+
res.setHeader("Strict-Transport-Security", "max-age=31536000");
|
|
181
|
+
next();
|
|
182
|
+
};
|
|
183
|
+
}
|
|
137
184
|
const EXPIRED = "<p>Este link de subida venció o ya no existe. Pedile al agente uno nuevo.</p>";
|
|
185
|
+
const USED = "<p>Ya se subieron archivos a este link y no se pueden cambiar. Si te equivocaste, pedile al agente uno nuevo.</p>";
|
|
138
186
|
function uploadForm(limits) {
|
|
139
187
|
const rules = [
|
|
140
188
|
limits.maxFiles ? `hasta ${limits.maxFiles} archivo(s)` : "",
|
package/build/index.js
CHANGED
|
@@ -1,30 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import "./shared/load-env.js";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
5
5
|
import { LocalSession } from "./aula-virtual/client/local-session.js";
|
|
6
|
+
import { defaultRegional } from "./aula-virtual/client/regionals.js";
|
|
6
7
|
import { StoredAulaSession } from "./aula-virtual/client/stored-session.js";
|
|
7
8
|
import { runLogin } from "./aula-virtual/login.js";
|
|
8
9
|
import { startHttpServer, userIdOf } from "./http.js";
|
|
9
10
|
import { accessPolicyFromEnv } from "./oauth/access.js";
|
|
11
|
+
import { redirectAllowlistFromEnv } from "./oauth/redirects.js";
|
|
10
12
|
import { createServer } from "./server.js";
|
|
11
13
|
import { logger } from "./shared/logger.js";
|
|
12
14
|
import { SqliteStore } from "./shared/store/sqlite-store.js";
|
|
13
15
|
import { UploadInbox } from "./shared/uploads.js";
|
|
14
|
-
const USAGE = `Uso:
|
|
15
|
-
utn-mcp servidor MCP por STDIO (lo lanza el cliente)
|
|
16
|
-
utn-mcp --http [--host H] [--port N] servidor MCP por HTTP (por defecto 127.0.0.1:3000)
|
|
17
|
-
utn-mcp login guardar credenciales de la UTN y obtener el primer token
|
|
18
|
-
utn-mcp login --http lo mismo, para el modo HTTP: guarda la sesión y emite el token del cliente
|
|
19
|
-
|
|
16
|
+
const USAGE = `Uso:
|
|
17
|
+
utn-mcp servidor MCP por STDIO (lo lanza el cliente)
|
|
18
|
+
utn-mcp --http [--host H] [--port N] servidor MCP por HTTP (por defecto 127.0.0.1:3000)
|
|
19
|
+
utn-mcp login guardar credenciales de la UTN y obtener el primer token
|
|
20
|
+
utn-mcp login --http lo mismo, para el modo HTTP: guarda la sesión y emite el token del cliente
|
|
21
|
+
|
|
20
22
|
Si hay un .env en la carpeta actual, se lee al arrancar (ver .env.example).`;
|
|
21
|
-
/** Lee el `.env` de la carpeta actual, si hay. Lo que ya está definido en el entorno no se pisa. */
|
|
22
|
-
function loadDotenv() {
|
|
23
|
-
if (existsSync(".env"))
|
|
24
|
-
process.loadEnvFile(".env");
|
|
25
|
-
}
|
|
26
23
|
async function main() {
|
|
27
|
-
loadDotenv();
|
|
28
24
|
const { values, positionals } = parseArgs({
|
|
29
25
|
allowPositionals: true,
|
|
30
26
|
options: {
|
|
@@ -39,6 +35,8 @@ async function main() {
|
|
|
39
35
|
console.error(USAGE);
|
|
40
36
|
return;
|
|
41
37
|
}
|
|
38
|
+
// Una UTN_REGIONAL que no existe corta acá: si no, recién fallaría al primer login o al primer pedido.
|
|
39
|
+
defaultRegional();
|
|
42
40
|
if (positionals[0] === "login") {
|
|
43
41
|
await runLogin(values.http);
|
|
44
42
|
return;
|
|
@@ -63,6 +61,7 @@ async function main() {
|
|
|
63
61
|
port,
|
|
64
62
|
publicUrl,
|
|
65
63
|
access: accessPolicyFromEnv(),
|
|
64
|
+
redirects: redirectAllowlistFromEnv(),
|
|
66
65
|
adminSecret: process.env.UTN_MCP_ADMIN_SECRET || undefined,
|
|
67
66
|
};
|
|
68
67
|
startHttpServer(factory, options, store, inbox);
|
package/build/oauth/access.js
CHANGED
|
@@ -23,18 +23,18 @@ function listPolicy(raw) {
|
|
|
23
23
|
return { allowed: false, reason: "Tu cuenta no está habilitada en este servidor. Pedile acceso a quien lo administra." };
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
-
/** `POST url` con `{ email, usuario }`; contesta `{ permitido, motivo?, url? }`. */
|
|
26
|
+
/** `POST url` con `{ email, usuario, regional }`; contesta `{ permitido, motivo?, url? }`. */
|
|
27
27
|
function remotePolicy(url, secret) {
|
|
28
28
|
if (!secret)
|
|
29
29
|
logger.warn("UTN_MCP_ACCESS_URL sin UTN_MCP_ACCESS_SECRET: el pedido de acceso va sin autenticar.");
|
|
30
|
-
return async ({ username, email }) => {
|
|
30
|
+
return async ({ regional, username, email }) => {
|
|
31
31
|
if (!email)
|
|
32
32
|
return { allowed: false, reason: "El aula no devolvió tu email, y sin él no podemos verificar tu acceso." };
|
|
33
33
|
try {
|
|
34
34
|
const res = await fetch(url, {
|
|
35
35
|
method: "POST",
|
|
36
36
|
headers: { "content-type": "application/json", ...(secret ? { authorization: `Bearer ${secret}` } : {}) },
|
|
37
|
-
body: JSON.stringify({ email, usuario: username }),
|
|
37
|
+
body: JSON.stringify({ email, usuario: username, regional }),
|
|
38
38
|
signal: AbortSignal.timeout(ACCESS_TIMEOUT_MS),
|
|
39
39
|
});
|
|
40
40
|
if (!res.ok)
|
package/build/oauth/admin.js
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
2
|
import express from "express";
|
|
3
|
+
import { linkAulaAccount } from "../aula-virtual/client/link.js";
|
|
4
|
+
import { availableRegionals } from "../aula-virtual/client/regionals.js";
|
|
5
|
+
import { InvalidCredentialsError } from "../aula-virtual/client/sso-login.js";
|
|
3
6
|
import { AULA_PLATFORM } from "../aula-virtual/client/stored-session.js";
|
|
7
|
+
import { logger } from "../shared/logger.js";
|
|
8
|
+
import { RateLimiter } from "./expiring.js";
|
|
9
|
+
/** Intentos de `/admin/verificar` por cuenta del aula cada 15 minutos: que no sirva para probar contraseñas. */
|
|
10
|
+
const VERIFY_ATTEMPTS = 5;
|
|
11
|
+
const VERIFY_WINDOW_MS = 15 * 60_000;
|
|
12
|
+
const SUPPORT = { verified: "verificada", candidate: "candidata", unsupported: "sin_soporte" };
|
|
13
|
+
/** Segundos desde epoch, como los guarda el store, a ISO. */
|
|
14
|
+
const isoDate = (seconds) => new Date(seconds * 1000).toISOString();
|
|
4
15
|
/**
|
|
5
|
-
* API para que otro servicio del que hostea (un panel de cuenta, por ejemplo) muestre y
|
|
6
|
-
* apps
|
|
7
|
-
* Los usuarios se buscan por email
|
|
16
|
+
* API para que otro servicio del que hostea (un panel de cuenta, por ejemplo) liste usuarios, muestre y
|
|
17
|
+
* desconecte sus apps, consulte las regionales y dé de alta cuentas vinculando su aula. Solo
|
|
18
|
+
* existe con UTN_MCP_ADMIN_SECRET, que va como `Authorization: Bearer`. Los usuarios se buscan por email.
|
|
8
19
|
*/
|
|
9
20
|
export function mountAdmin(app, store, secret) {
|
|
10
21
|
const json = express.json({ limit: "4kb" });
|
|
22
|
+
const verifyLimiter = new RateLimiter(VERIFY_ATTEMPTS, VERIFY_WINDOW_MS);
|
|
11
23
|
const requireSecret = (req, res, next) => {
|
|
12
24
|
const got = Buffer.from(req.headers.authorization ?? "");
|
|
13
25
|
const want = Buffer.from(`Bearer ${secret}`);
|
|
@@ -15,25 +27,34 @@ export function mountAdmin(app, store, secret) {
|
|
|
15
27
|
return next();
|
|
16
28
|
res.status(401).json({ error: "No autorizado." });
|
|
17
29
|
};
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
const email = typeof req.body?.email === "string" ? req.body.email : "";
|
|
21
|
-
const user = email ? await store.findUserByEmail(email) : null;
|
|
22
|
-
if (!user)
|
|
23
|
-
return void res.json({ clientes: [] });
|
|
30
|
+
/** El usuario, su regional, cuándo se renovó su sesión del aula y sus apps conectadas. */
|
|
31
|
+
async function summary(user) {
|
|
24
32
|
const [clients, aulaAt] = await Promise.all([
|
|
25
33
|
store.listConnectedClients(user.id),
|
|
26
34
|
store.sessionUpdatedAt(user.id, AULA_PLATFORM),
|
|
27
35
|
]);
|
|
28
|
-
|
|
36
|
+
return {
|
|
29
37
|
usuario: user.utnUsername,
|
|
30
|
-
|
|
38
|
+
regional: user.regional,
|
|
39
|
+
sesionAula: aulaAt ? isoDate(aulaAt) : undefined,
|
|
31
40
|
clientes: clients.map((c) => ({
|
|
32
41
|
id: c.clientId,
|
|
33
42
|
nombre: c.clientName ?? (c.clientId === "local" ? "Token de utn-mcp login" : "App sin nombre"),
|
|
34
|
-
ultimoUso:
|
|
43
|
+
ultimoUso: isoDate(c.lastTokenAt),
|
|
35
44
|
})),
|
|
36
|
-
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** `{ email }` → el resumen de ese usuario. */
|
|
48
|
+
app.post("/admin/clientes", requireSecret, json, async (req, res) => {
|
|
49
|
+
const email = typeof req.body?.email === "string" ? req.body.email : "";
|
|
50
|
+
const user = email ? await store.findUserByEmail(email) : null;
|
|
51
|
+
res.json(user ? await summary(user) : { clientes: [] });
|
|
52
|
+
});
|
|
53
|
+
/** Todos los usuarios con su resumen, para el panel de quien administra el servidor. */
|
|
54
|
+
app.post("/admin/usuarios", requireSecret, async (_req, res) => {
|
|
55
|
+
const users = await store.listUsers();
|
|
56
|
+
const usuarios = await Promise.all(users.map(async (u) => ({ email: u.email || undefined, alta: isoDate(u.createdAt), ...(await summary(u)) })));
|
|
57
|
+
res.json({ usuarios });
|
|
37
58
|
});
|
|
38
59
|
/** `{ email, cliente }` → borra los tokens de esa app: tiene que volver a conectarse. */
|
|
39
60
|
app.post("/admin/clientes/revocar", requireSecret, json, async (req, res) => {
|
|
@@ -45,4 +66,50 @@ export function mountAdmin(app, store, secret) {
|
|
|
45
66
|
await store.revokeClient(user.id, clientId);
|
|
46
67
|
res.json({ revocado: true });
|
|
47
68
|
});
|
|
69
|
+
/** Las regionales que se pueden elegir, con su soporte en este servidor. */
|
|
70
|
+
app.post("/admin/regionales", requireSecret, async (_req, res) => {
|
|
71
|
+
const regionales = availableRegionals(await store.listVerifiedRegionals()).map((r) => ({
|
|
72
|
+
id: r.id,
|
|
73
|
+
nombre: r.name,
|
|
74
|
+
soporte: SUPPORT[r.support],
|
|
75
|
+
}));
|
|
76
|
+
res.json({ regionales });
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* `{ regional, usuario, contrasena }` → entra al aula como el login de OAuth: crea el usuario si no
|
|
80
|
+
* existe y le guarda la sesión, así ya puede usar las tools. Devuelve `{ usuario, nombre, email,
|
|
81
|
+
* primero? }`, con `primero` si fue el primero en verificar la regional; si no entra, `{ error, motivo }`.
|
|
82
|
+
*/
|
|
83
|
+
app.post("/admin/verificar", requireSecret, json, async (req, res) => {
|
|
84
|
+
const id = typeof req.body?.regional === "string" ? req.body.regional : "";
|
|
85
|
+
const username = typeof req.body?.usuario === "string" ? req.body.usuario.trim() : "";
|
|
86
|
+
const password = typeof req.body?.contrasena === "string" ? req.body.contrasena : "";
|
|
87
|
+
const regional = availableRegionals(await store.listVerifiedRegionals()).find((r) => r.id === id);
|
|
88
|
+
if (!regional || !username || !password) {
|
|
89
|
+
return void res.status(400).json({ error: "pedido", motivo: "Faltan la regional, el usuario o la contraseña." });
|
|
90
|
+
}
|
|
91
|
+
if (regional.support === "unsupported") {
|
|
92
|
+
return void res.status(422).json({ error: "sin_soporte", motivo: `Todavía no hay soporte para ${regional.name}.` });
|
|
93
|
+
}
|
|
94
|
+
if (!verifyLimiter.allow(`${regional.id}:${username.toLowerCase()}`)) {
|
|
95
|
+
return void res.status(429).json({ error: "intentos", motivo: "Demasiados intentos. Esperá unos minutos." });
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const account = await linkAulaAccount(store, regional, { username, password });
|
|
99
|
+
logger.info(`Admin: ${account.username} (${regional.id}) vinculó su aula.`);
|
|
100
|
+
res.json({
|
|
101
|
+
usuario: account.username,
|
|
102
|
+
nombre: account.fullname,
|
|
103
|
+
email: account.email ?? undefined,
|
|
104
|
+
primero: account.firstVerifier || undefined,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
if (e instanceof InvalidCredentialsError) {
|
|
109
|
+
return void res.status(401).json({ error: "credenciales", motivo: "El aula rechazó el usuario o la contraseña." });
|
|
110
|
+
}
|
|
111
|
+
logger.warn(`Verificación de ${username} (${regional.id}): ${e.message}`);
|
|
112
|
+
res.status(502).json({ error: "aula", motivo: `No pudimos entrar al aula de ${regional.name}.` });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
48
115
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Los callbacks de los clientes MCP conocidos (Claude, ChatGPT, Grok, Gemini, VS Code web) y los esquemas de editores. */
|
|
2
|
+
const DEFAULT_HOSTS = [
|
|
3
|
+
"claude.ai",
|
|
4
|
+
"claude.com",
|
|
5
|
+
"chatgpt.com",
|
|
6
|
+
"chat.openai.com",
|
|
7
|
+
"grok.com",
|
|
8
|
+
"gemini.google.com",
|
|
9
|
+
"vscode.dev",
|
|
10
|
+
"insiders.vscode.dev",
|
|
11
|
+
];
|
|
12
|
+
const DEFAULT_SCHEMES = ["cursor:", "vscode:", "vscode-insiders:", "windsurf:"];
|
|
13
|
+
/** En loopback la redirección queda en la compu del usuario: ahí escuchan los clientes de escritorio. */
|
|
14
|
+
const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"];
|
|
15
|
+
/**
|
|
16
|
+
* Los conocidos más los de UTN_MCP_REDIRECT_ALLOW, separados por coma: hosts (`app.ejemplo.com`)
|
|
17
|
+
* o esquemas terminados en dos puntos (`miapp:`).
|
|
18
|
+
*/
|
|
19
|
+
export function redirectAllowlistFromEnv(env = process.env) {
|
|
20
|
+
const extra = (env.UTN_MCP_REDIRECT_ALLOW ?? "").split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
|
|
21
|
+
return {
|
|
22
|
+
hosts: new Set([...DEFAULT_HOSTS, ...extra.filter((e) => !e.endsWith(":"))]),
|
|
23
|
+
schemes: new Set([...DEFAULT_SCHEMES, ...extra.filter((e) => e.endsWith(":"))]),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* `trusted`: loopback, un host HTTPS de la lista o el esquema de una app de la lista.
|
|
28
|
+
* `unknown`: cualquier otro host HTTPS, como el de un agente propio; se acepta con aviso.
|
|
29
|
+
* `rejected`: el resto.
|
|
30
|
+
*/
|
|
31
|
+
export function redirectTrust(uri, allow) {
|
|
32
|
+
if (typeof uri !== "string")
|
|
33
|
+
return "rejected";
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(uri);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return "rejected";
|
|
40
|
+
}
|
|
41
|
+
if (url.hash || url.username || url.password)
|
|
42
|
+
return "rejected";
|
|
43
|
+
if (url.protocol === "http:" || url.protocol === "https:") {
|
|
44
|
+
if (LOOPBACK.includes(url.hostname))
|
|
45
|
+
return "trusted";
|
|
46
|
+
if (url.protocol === "http:")
|
|
47
|
+
return "rejected";
|
|
48
|
+
return allow.hosts.has(url.hostname) ? "trusted" : "unknown";
|
|
49
|
+
}
|
|
50
|
+
return allow.schemes.has(url.protocol) ? "trusted" : "rejected";
|
|
51
|
+
}
|
|
52
|
+
/** Cómo se le nombra al usuario el destino. */
|
|
53
|
+
export function describeRedirect(uri) {
|
|
54
|
+
const url = new URL(uri);
|
|
55
|
+
if (LOOPBACK.includes(url.hostname))
|
|
56
|
+
return "una app en tu compu";
|
|
57
|
+
if (url.protocol === "https:")
|
|
58
|
+
return url.hostname;
|
|
59
|
+
return `la app ${url.protocol.slice(0, -1)}`;
|
|
60
|
+
}
|