@hubex/mcp 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/.env.example +54 -0
- package/CONNECTING.md +260 -0
- package/README.md +266 -0
- package/dist/auth.js +226 -0
- package/dist/config.js +120 -0
- package/dist/generated/manifest.js +3643 -0
- package/dist/guides/field-notes.json +27 -0
- package/dist/guides/loader.js +32 -0
- package/dist/guides/service-map.js +6 -0
- package/dist/http.js +93 -0
- package/dist/index/store.js +134 -0
- package/dist/index/types.js +1 -0
- package/dist/index.js +20 -0
- package/dist/paths.js +20 -0
- package/dist/pii/fields.js +100 -0
- package/dist/pii/mask.js +50 -0
- package/dist/pii/strategies.js +88 -0
- package/dist/schema/build-index.js +67 -0
- package/dist/schema/deref.js +87 -0
- package/dist/schema/describe.js +65 -0
- package/dist/server.js +62 -0
- package/dist/token-prompt.js +41 -0
- package/dist/tools/curated.js +151 -0
- package/dist/tools/discovery.js +187 -0
- package/dist/tools/guides.js +126 -0
- package/dist/tools/registry.js +37 -0
- package/dist/tools/request.js +170 -0
- package/dist/tools/types.js +1 -0
- package/docs/guides/assets.md +334 -0
- package/docs/guides/attributes.md +125 -0
- package/docs/guides/checklisttemplates.md +154 -0
- package/docs/guides/companies.md +57 -0
- package/docs/guides/dictionaries.md +64 -0
- package/docs/guides/lifecycle.md +263 -0
- package/docs/guides/materials.md +135 -0
- package/docs/guides/notifications.md +184 -0
- package/docs/guides/roles.md +125 -0
- package/docs/guides/sla.md +130 -0
- package/docs/guides/start.md +67 -0
- package/docs/guides/taskchecklists.md +149 -0
- package/docs/guides/taskcreate.md +260 -0
- package/docs/guides/taskedit.md +277 -0
- package/docs/guides/tasktypes.md +156 -0
- package/docs/guides/users.md +71 -0
- package/generated/index.dev.json +18776 -0
- package/generated/index.prod.json +18858 -0
- package/package.json +48 -0
- package/swagger/dev/ADM.json +27777 -0
- package/swagger/dev/AUTH.json +1739 -0
- package/swagger/dev/AUTHN.json +1250 -0
- package/swagger/dev/AUTHZ.json +1404 -0
- package/swagger/dev/CM.json +309 -0
- package/swagger/dev/COMMON.json +6543 -0
- package/swagger/dev/ES.json +28029 -0
- package/swagger/dev/EXPORT.json +4575 -0
- package/swagger/dev/IMPORT.json +1479 -0
- package/swagger/dev/LIC.json +224 -0
- package/swagger/dev/MSG.json +7883 -0
- package/swagger/dev/NEWS.json +348 -0
- package/swagger/dev/PA.json +5981 -0
- package/swagger/dev/PMP.json +3196 -0
- package/swagger/dev/PROXY.json +416 -0
- package/swagger/dev/REPORT.json +3921 -0
- package/swagger/dev/SC.json +3771 -0
- package/swagger/dev/SLA.json +2837 -0
- package/swagger/dev/TSTG.json +4981 -0
- package/swagger/dev/UI.json +4720 -0
- package/swagger/dev/WH.json +16796 -0
- package/swagger/dev/WORK.json +36024 -0
- package/swagger/dev/WSP.json +1612 -0
- package/swagger/prod/ADM.json +27777 -0
- package/swagger/prod/AUTH.json +1308 -0
- package/swagger/prod/AUTHN.json +2710 -0
- package/swagger/prod/AUTHZ.json +896 -0
- package/swagger/prod/CM.json +162 -0
- package/swagger/prod/COMMON.json +4910 -0
- package/swagger/prod/ES.json +28029 -0
- package/swagger/prod/EXPORT.json +3091 -0
- package/swagger/prod/LIC.json +123 -0
- package/swagger/prod/MSG.json +6239 -0
- package/swagger/prod/NEWS.json +295 -0
- package/swagger/prod/PA.json +5123 -0
- package/swagger/prod/PMP.json +2978 -0
- package/swagger/prod/PROXY.json +250 -0
- package/swagger/prod/REPORT.json +3729 -0
- package/swagger/prod/SC.json +3771 -0
- package/swagger/prod/SLA.json +2201 -0
- package/swagger/prod/TSTG.json +4220 -0
- package/swagger/prod/UI.json +3879 -0
- package/swagger/prod/WH.json +16730 -0
- package/swagger/prod/WORK.json +35994 -0
- package/swagger/prod/WSP.json +1468 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"taskTypeID": {
|
|
3
|
+
"description": "Тип заявки.",
|
|
4
|
+
"required": true,
|
|
5
|
+
"lookup": "WORK:GET:/TaskTypes"
|
|
6
|
+
},
|
|
7
|
+
"requestMethodID": {
|
|
8
|
+
"description": "Способ подачи заявки. Для интеграций используется значение 4.",
|
|
9
|
+
"required": true,
|
|
10
|
+
"lookup": "WORK:GET:/RequestMethods"
|
|
11
|
+
},
|
|
12
|
+
"workTypeID": { "description": "Вид работ.", "lookup": "WORK:GET:/WorkTypes" },
|
|
13
|
+
"taskStatusID": {
|
|
14
|
+
"description": "Статус заявки. Переход должен быть разрешён жизненным циклом.",
|
|
15
|
+
"lookup": "WORK:GET:/TaskStatuses"
|
|
16
|
+
},
|
|
17
|
+
"companyID": { "description": "Компания-заказчик.", "lookup": "ES:GET:/Companies" },
|
|
18
|
+
"assetID": { "description": "Объект обслуживания.", "lookup": "ES:GET:/Assets" },
|
|
19
|
+
"assetTypeID": { "description": "Тип объекта.", "lookup": "ES:GET:/AssetTypes" },
|
|
20
|
+
"assetClassID": { "description": "Класс объекта.", "lookup": "ES:GET:/AssetClasses" },
|
|
21
|
+
"locationID": { "description": "Адрес/локация.", "lookup": "ES:GET:/Locations" },
|
|
22
|
+
"userID": { "description": "Пользователь HubEx.", "lookup": "ADM:GET:/Users/short" },
|
|
23
|
+
"roleID": { "description": "Роль пользователя.", "lookup": "ADM:GET:/Roles" },
|
|
24
|
+
"notes": { "description": "Описание без HTML — показывается в списке заявок." },
|
|
25
|
+
"notesHtml": { "description": "Описание с HTML — показывается в карточке заявки." },
|
|
26
|
+
"deadline": { "description": "Крайний срок закрытия, ISO 8601." }
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Чтение markdown-гайдов и оверлея аннотаций полей.
|
|
3
|
+
* Гайды лежат в docs/guides и грузятся только по явному запросу.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { GUIDES_DIR } from "../paths.js";
|
|
9
|
+
const NOTES_FILE = join(dirname(fileURLToPath(import.meta.url)), "field-notes.json");
|
|
10
|
+
let notesCache;
|
|
11
|
+
export function loadFieldNotes() {
|
|
12
|
+
if (!notesCache) {
|
|
13
|
+
notesCache = JSON.parse(readFileSync(NOTES_FILE, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
return notesCache;
|
|
16
|
+
}
|
|
17
|
+
const guideCache = new Map();
|
|
18
|
+
export function loadGuide(topic, dir = GUIDES_DIR) {
|
|
19
|
+
const cached = guideCache.get(topic);
|
|
20
|
+
if (cached)
|
|
21
|
+
return cached;
|
|
22
|
+
if (!/^[a-z]+$/.test(topic)) {
|
|
23
|
+
throw new Error(`Некорректный топик гайда: ${topic}`);
|
|
24
|
+
}
|
|
25
|
+
const file = join(dir, `${topic}.md`);
|
|
26
|
+
if (!existsSync(file)) {
|
|
27
|
+
throw new Error(`Гайд "${topic}" не найден.`);
|
|
28
|
+
}
|
|
29
|
+
const text = readFileSync(file, "utf8");
|
|
30
|
+
guideCache.set(topic, text);
|
|
31
|
+
return text;
|
|
32
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin HTTP client for HubEx services. Builds `{base}/fsm/{SERVICE}{path}`,
|
|
3
|
+
* injects auth + `X-Application-ID`, and normalizes responses and errors.
|
|
4
|
+
*/
|
|
5
|
+
import { maskPii } from "./pii/mask.js";
|
|
6
|
+
export class HubexApiError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
service;
|
|
9
|
+
path;
|
|
10
|
+
payload;
|
|
11
|
+
constructor(status, service, path, payload) {
|
|
12
|
+
super(HubexApiError.format(status, service, path, payload));
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.service = service;
|
|
15
|
+
this.path = path;
|
|
16
|
+
this.payload = payload;
|
|
17
|
+
this.name = "HubexApiError";
|
|
18
|
+
}
|
|
19
|
+
static format(status, service, path, payload) {
|
|
20
|
+
let detail = "";
|
|
21
|
+
if (typeof payload === "string") {
|
|
22
|
+
detail = payload;
|
|
23
|
+
}
|
|
24
|
+
else if (Array.isArray(payload) && payload.length > 0) {
|
|
25
|
+
// HubEx error shape: [{ traceIdentifier, code, message }]
|
|
26
|
+
const first = payload[0];
|
|
27
|
+
detail = [first.code, first.message, first.traceIdentifier].filter(Boolean).join(" | ");
|
|
28
|
+
}
|
|
29
|
+
else if (payload && typeof payload === "object") {
|
|
30
|
+
detail = JSON.stringify(payload).slice(0, 800);
|
|
31
|
+
}
|
|
32
|
+
return `HubEx ${service} ${path} -> HTTP ${status}${detail ? `: ${detail}` : ""}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function buildQueryString(query) {
|
|
36
|
+
if (!query)
|
|
37
|
+
return "";
|
|
38
|
+
const params = new URLSearchParams();
|
|
39
|
+
for (const [key, value] of Object.entries(query)) {
|
|
40
|
+
if (value === undefined || value === null)
|
|
41
|
+
continue;
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
for (const v of value)
|
|
44
|
+
params.append(key, String(v));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
params.append(key, String(value));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const s = params.toString();
|
|
51
|
+
return s ? `?${s}` : "";
|
|
52
|
+
}
|
|
53
|
+
export class HubexClient {
|
|
54
|
+
config;
|
|
55
|
+
auth;
|
|
56
|
+
constructor(config, auth) {
|
|
57
|
+
this.config = config;
|
|
58
|
+
this.auth = auth;
|
|
59
|
+
}
|
|
60
|
+
async request(req) {
|
|
61
|
+
const token = await this.auth.getAccessToken();
|
|
62
|
+
const url = `${this.config.apiBaseUrl}/${req.service}${req.path}` + buildQueryString(req.query);
|
|
63
|
+
const headers = {
|
|
64
|
+
Authorization: `Bearer ${token}`,
|
|
65
|
+
"X-Application-ID": this.config.applicationId,
|
|
66
|
+
Accept: "application/json",
|
|
67
|
+
};
|
|
68
|
+
const init = { method: req.method, headers };
|
|
69
|
+
if (req.body !== undefined && req.method !== "GET" && req.method !== "HEAD") {
|
|
70
|
+
headers["Content-Type"] = "application/json";
|
|
71
|
+
init.body = JSON.stringify(req.body);
|
|
72
|
+
}
|
|
73
|
+
const res = await fetch(url, init);
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
let data = undefined;
|
|
76
|
+
if (text) {
|
|
77
|
+
try {
|
|
78
|
+
data = JSON.parse(text);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
data = text;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Маскирование стоит здесь, а не в инструментах: так покрыты сразу все
|
|
85
|
+
// потребители клиента, включая текст ошибок. Авторизация ходит мимо
|
|
86
|
+
// HubexClient (src/auth.ts), поэтому токены сессии не задеваются.
|
|
87
|
+
const visible = this.config.maskPii ? maskPii(data) : data;
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
throw new HubexApiError(res.status, req.service, req.path, visible ?? text);
|
|
90
|
+
}
|
|
91
|
+
return { status: res.status, data: visible };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Индекс эндпоинтов в памяти: фильтрация по разрешённым методам и сервисам,
|
|
3
|
+
* перечисление скоупов и поиск. Полные схемы здесь не хранятся.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
export const DEFAULT_SEARCH_LIMIT = 30;
|
|
7
|
+
export const MAX_SEARCH_LIMIT = 100;
|
|
8
|
+
export function loadIndexFile(file) {
|
|
9
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
10
|
+
}
|
|
11
|
+
export class EndpointIndex {
|
|
12
|
+
data;
|
|
13
|
+
byId = new Map();
|
|
14
|
+
allowedMethods;
|
|
15
|
+
allowedServices;
|
|
16
|
+
constructor(data, opts) {
|
|
17
|
+
this.data = data;
|
|
18
|
+
this.allowedMethods = new Set(opts.methods);
|
|
19
|
+
if (opts.services) {
|
|
20
|
+
const known = new Set(data.services.map((s) => s.code));
|
|
21
|
+
const unknown = opts.services.filter((s) => !known.has(s));
|
|
22
|
+
if (unknown.length > 0) {
|
|
23
|
+
throw new Error(`HUBEX_SERVICES содержит неизвестный сервис(ы): ${unknown.join(", ")}. ` +
|
|
24
|
+
`Известные сервисы: ${[...known].sort().join(", ")}. Проверь переменную окружения HUBEX_SERVICES.`);
|
|
25
|
+
}
|
|
26
|
+
this.allowedServices = new Set(opts.services);
|
|
27
|
+
}
|
|
28
|
+
for (const e of data.endpoints)
|
|
29
|
+
this.byId.set(e.id, e);
|
|
30
|
+
}
|
|
31
|
+
isAllowed(entry) {
|
|
32
|
+
if (!this.allowedMethods.has(entry.method))
|
|
33
|
+
return false;
|
|
34
|
+
if (this.allowedServices && !this.allowedServices.has(entry.service))
|
|
35
|
+
return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
isServiceAllowed(code) {
|
|
39
|
+
return !this.allowedServices || this.allowedServices.has(code);
|
|
40
|
+
}
|
|
41
|
+
get(id) {
|
|
42
|
+
return this.byId.get(id);
|
|
43
|
+
}
|
|
44
|
+
listServices() {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const svc of this.data.services) {
|
|
47
|
+
if (!this.isServiceAllowed(svc.code))
|
|
48
|
+
continue;
|
|
49
|
+
const counts = this.countMethods(this.data.endpoints.filter((e) => e.service === svc.code && this.isAllowed(e)));
|
|
50
|
+
if (Object.keys(counts).length === 0)
|
|
51
|
+
continue;
|
|
52
|
+
out.push({ code: svc.code, title: svc.title, description: svc.description, counts });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
listTags(service) {
|
|
57
|
+
const code = service.toUpperCase();
|
|
58
|
+
if (!this.data.services.some((s) => s.code === code)) {
|
|
59
|
+
throw new Error(`Неизвестный сервис: ${service}. Вызови hubex_list_scopes без аргументов, чтобы увидеть список.`);
|
|
60
|
+
}
|
|
61
|
+
if (!this.isServiceAllowed(code)) {
|
|
62
|
+
throw new Error(`Сервис ${code} отключён настройкой HUBEX_SERVICES. Вызови hubex_list_scopes без аргументов, чтобы увидеть список доступных сервисов.`);
|
|
63
|
+
}
|
|
64
|
+
const groups = new Map();
|
|
65
|
+
for (const e of this.data.endpoints) {
|
|
66
|
+
if (e.service !== code || !this.isAllowed(e))
|
|
67
|
+
continue;
|
|
68
|
+
for (const tag of e.tags.length > 0 ? e.tags : ["(без тега)"]) {
|
|
69
|
+
const list = groups.get(tag) ?? [];
|
|
70
|
+
list.push(e);
|
|
71
|
+
groups.set(tag, list);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...groups.entries()]
|
|
75
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
76
|
+
.map(([tag, entries]) => ({ tag, counts: this.countMethods(entries) }));
|
|
77
|
+
}
|
|
78
|
+
search(opts) {
|
|
79
|
+
const groupMethods = new Set(opts.methods.filter((m) => this.allowedMethods.has(m)));
|
|
80
|
+
const scope = opts.scope?.toUpperCase();
|
|
81
|
+
const tag = opts.tag?.toLowerCase();
|
|
82
|
+
const tokens = (opts.query ?? "")
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.split(/\s+/)
|
|
85
|
+
.filter((t) => t !== "");
|
|
86
|
+
const limit = Math.min(Math.max(opts.limit ?? DEFAULT_SEARCH_LIMIT, 1), MAX_SEARCH_LIMIT);
|
|
87
|
+
const scored = [];
|
|
88
|
+
for (const entry of this.data.endpoints) {
|
|
89
|
+
if (!groupMethods.has(entry.method))
|
|
90
|
+
continue;
|
|
91
|
+
if (!this.isAllowed(entry))
|
|
92
|
+
continue;
|
|
93
|
+
if (scope && entry.service !== scope)
|
|
94
|
+
continue;
|
|
95
|
+
if (tag && !entry.tags.some((t) => t.toLowerCase() === tag))
|
|
96
|
+
continue;
|
|
97
|
+
const score = scoreEntry(entry, tokens);
|
|
98
|
+
if (score === null)
|
|
99
|
+
continue;
|
|
100
|
+
scored.push({ entry, score });
|
|
101
|
+
}
|
|
102
|
+
scored.sort((a, b) => b.score - a.score || a.entry.id.localeCompare(b.entry.id));
|
|
103
|
+
return { results: scored.slice(0, limit).map((s) => s.entry), total: scored.length };
|
|
104
|
+
}
|
|
105
|
+
countMethods(entries) {
|
|
106
|
+
const counts = {};
|
|
107
|
+
for (const e of entries)
|
|
108
|
+
counts[e.method] = (counts[e.method] ?? 0) + 1;
|
|
109
|
+
return counts;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Все токены запроса должны найтись хотя бы в одном из полей.
|
|
114
|
+
* Вес совпадения: путь > тег > summary — путь на английском и обычно точнее.
|
|
115
|
+
*/
|
|
116
|
+
function scoreEntry(entry, tokens) {
|
|
117
|
+
if (tokens.length === 0)
|
|
118
|
+
return 0;
|
|
119
|
+
const path = entry.path.toLowerCase();
|
|
120
|
+
const tags = entry.tags.join(" ").toLowerCase();
|
|
121
|
+
const summary = entry.summary.toLowerCase();
|
|
122
|
+
let total = 0;
|
|
123
|
+
for (const token of tokens) {
|
|
124
|
+
if (path.includes(token))
|
|
125
|
+
total += 3;
|
|
126
|
+
else if (tags.includes(token))
|
|
127
|
+
total += 2;
|
|
128
|
+
else if (summary.includes(token))
|
|
129
|
+
total += 1;
|
|
130
|
+
else
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return total;
|
|
134
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Entry point: load config and serve HubEx tools over stdio.
|
|
4
|
+
*/
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { createServer } from "./server.js";
|
|
8
|
+
async function main() {
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
const server = createServer(config);
|
|
11
|
+
const transport = new StdioServerTransport();
|
|
12
|
+
await server.connect(transport);
|
|
13
|
+
// Log to stderr only — stdout is the MCP protocol channel.
|
|
14
|
+
console.error(`[hubex-mcp] connected. env=${config.env} tenant=${config.tenantId ?? "(from token)"} ` +
|
|
15
|
+
`authMode=${config.authMode} readonly=${config.readonly}`);
|
|
16
|
+
}
|
|
17
|
+
main().catch((err) => {
|
|
18
|
+
console.error("[hubex-mcp] fatal:", err instanceof Error ? err.message : err);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Расположение файлов данных относительно корня пакета.
|
|
3
|
+
* Работает и из `src/` (tsx), и из `dist/` (сборка): оба лежат на один уровень
|
|
4
|
+
* ниже корня, поэтому `..` от каталога этого модуля указывает на корень.
|
|
5
|
+
*/
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
export const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
export const GENERATED_DIR = join(PACKAGE_ROOT, "generated");
|
|
10
|
+
/** Корень кэша swagger; сами схемы лежат в подкаталоге каталога (dev/prod). */
|
|
11
|
+
export const SWAGGER_ROOT = join(PACKAGE_ROOT, "swagger");
|
|
12
|
+
export const GUIDES_DIR = join(PACKAGE_ROOT, "docs", "guides");
|
|
13
|
+
/** Каталог swagger-схем одного контура: swagger/dev или swagger/prod. */
|
|
14
|
+
export function swaggerDirFor(catalog) {
|
|
15
|
+
return join(SWAGGER_ROOT, catalog);
|
|
16
|
+
}
|
|
17
|
+
/** Индекс эндпоинтов одного контура: generated/index.dev.json или index.prod.json. */
|
|
18
|
+
export function indexFileFor(catalog) {
|
|
19
|
+
return join(GENERATED_DIR, `index.${catalog}.json`);
|
|
20
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Реестр имён полей, которые считаются персональными данными, и тип маски
|
|
3
|
+
* для каждого. Поиск идёт по имени ключа JSON, а не по значению: это
|
|
4
|
+
* работает на любых формах ответов HubEx и не зависит от swagger-схем.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Ключи хранятся в нижнем регистре: имена полей в разных сервисах HubEx
|
|
8
|
+
* отличаются регистром (`email` / `eMail`, `coordinate` / `Coordinate`).
|
|
9
|
+
*/
|
|
10
|
+
const FIELDS = {
|
|
11
|
+
// Части ФИО
|
|
12
|
+
firstname: "name",
|
|
13
|
+
middlename: "name",
|
|
14
|
+
lastname: "name",
|
|
15
|
+
leadfirstname: "name",
|
|
16
|
+
leadmiddlename: "name",
|
|
17
|
+
leadlastname: "name",
|
|
18
|
+
ownerfirstname: "name",
|
|
19
|
+
ownermiddlename: "name",
|
|
20
|
+
ownerlastname: "name",
|
|
21
|
+
membername: "name",
|
|
22
|
+
// ФИО целиком (несколько слов в одной строке)
|
|
23
|
+
fullname: "fullName",
|
|
24
|
+
managerfullname: "fullName",
|
|
25
|
+
userfullname: "fullName",
|
|
26
|
+
tenantfullname: "fullName",
|
|
27
|
+
signatory: "fullName",
|
|
28
|
+
// По swagger эти поля содержат ФИО целиком («Иванов Иван Иванович»),
|
|
29
|
+
// в ссылочной форме {id, name} — там же, под ключом name.
|
|
30
|
+
contactperson: "fullName",
|
|
31
|
+
responsibleperson: "fullName",
|
|
32
|
+
acceptedperson: "fullName",
|
|
33
|
+
customassetresponsibleperson: "fullName",
|
|
34
|
+
// Почта
|
|
35
|
+
email: "email",
|
|
36
|
+
// Защитная запись: в swagger-схемах HubEx поле не встречается.
|
|
37
|
+
emails: "email",
|
|
38
|
+
contactemail: "email",
|
|
39
|
+
manageremail: "email",
|
|
40
|
+
supportemail: "email",
|
|
41
|
+
customemaillist: "email",
|
|
42
|
+
recipient: "email",
|
|
43
|
+
lastrecipient: "email",
|
|
44
|
+
// Телефоны
|
|
45
|
+
phone: "phone",
|
|
46
|
+
phone1: "phone",
|
|
47
|
+
phone2: "phone",
|
|
48
|
+
phone01: "phone",
|
|
49
|
+
phone02: "phone",
|
|
50
|
+
phone03: "phone",
|
|
51
|
+
mobilephone: "phone",
|
|
52
|
+
workphone: "phone",
|
|
53
|
+
otherphone: "phone",
|
|
54
|
+
contactphone: "phone",
|
|
55
|
+
managerphone: "phone",
|
|
56
|
+
supportphone: "phone",
|
|
57
|
+
customphonelist: "phone",
|
|
58
|
+
// Адреса
|
|
59
|
+
address: "address",
|
|
60
|
+
lawaddress: "address",
|
|
61
|
+
postaddress: "address",
|
|
62
|
+
registeredoffice: "address",
|
|
63
|
+
// Секреты: маскируются целиком, длина не сохраняется
|
|
64
|
+
password: "secret",
|
|
65
|
+
currentpassword: "secret",
|
|
66
|
+
// Защитная запись: в swagger-схемах HubEx поле не встречается.
|
|
67
|
+
newpassword: "secret",
|
|
68
|
+
token: "secret",
|
|
69
|
+
tokens: "secret",
|
|
70
|
+
access_token: "secret",
|
|
71
|
+
refresh_token: "secret",
|
|
72
|
+
refreshtoken: "secret",
|
|
73
|
+
servicetoken: "secret",
|
|
74
|
+
onetimelogintoken: "secret",
|
|
75
|
+
pushtoken: "secret",
|
|
76
|
+
verificationcodehash: "secret",
|
|
77
|
+
codehash: "secret",
|
|
78
|
+
// Ссылка на фото
|
|
79
|
+
avatarurl: "url",
|
|
80
|
+
// Логины и прочие идентификаторы физлица
|
|
81
|
+
login: "misc",
|
|
82
|
+
domainlogin: "misc",
|
|
83
|
+
accountdomainlogin: "misc",
|
|
84
|
+
personnelnumber: "misc",
|
|
85
|
+
inn: "misc",
|
|
86
|
+
tin: "misc",
|
|
87
|
+
credentials: "misc",
|
|
88
|
+
checkingaccount: "misc",
|
|
89
|
+
correspondingaccount: "misc",
|
|
90
|
+
// Координаты: числа, огрубляются вместо замены звёздочками
|
|
91
|
+
latitude: "geo",
|
|
92
|
+
longitude: "geo",
|
|
93
|
+
// Строковые координаты вида «55.7558, 37.6173» и «LAT:LNG».
|
|
94
|
+
coordinate: "geo",
|
|
95
|
+
center: "geo",
|
|
96
|
+
};
|
|
97
|
+
/** Тип маски для имени поля, либо undefined, если поле не является ПДн. */
|
|
98
|
+
export function lookupPiiField(key) {
|
|
99
|
+
return FIELDS[key.toLowerCase()];
|
|
100
|
+
}
|
package/dist/pii/mask.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Рекурсивный обход разобранного JSON-ответа HubEx: каждое поле, чьё имя
|
|
3
|
+
* числится в реестре ПДн, заменяется маской. Решение принимается по имени
|
|
4
|
+
* ключа, поэтому обход одинаково работает на словарях `{"1": {...}}`,
|
|
5
|
+
* массивах и произвольной вложенности.
|
|
6
|
+
*/
|
|
7
|
+
import { lookupPiiField } from "./fields.js";
|
|
8
|
+
import { maskValue } from "./strategies.js";
|
|
9
|
+
/** Предохранитель от аномально глубоких ответов. */
|
|
10
|
+
const MAX_DEPTH = 32;
|
|
11
|
+
/**
|
|
12
|
+
* Ключи-обёртки ссылочной формы `{id, name}` (`IdNameResult`): под ними лежит
|
|
13
|
+
* значение родительского поля. Сами по себе они не ПДн — `name` подписывает и
|
|
14
|
+
* типы задач, и страны, — поэтому маска переносится только от PII-родителя.
|
|
15
|
+
*/
|
|
16
|
+
const WRAPPER_KEYS = new Set(["name", "value"]);
|
|
17
|
+
function isPlainObject(value) {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
/** Присвоение через defineProperty: обычное `out[key]` на `__proto__` теряет данные. */
|
|
21
|
+
function setOwn(out, key, value) {
|
|
22
|
+
Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `kind` — маска, унаследованная от PII-поля-родителя. Она передаётся сквозь
|
|
26
|
+
* массивы и ровно на один уровень вглубь объекта.
|
|
27
|
+
*/
|
|
28
|
+
function walk(data, depth, kind) {
|
|
29
|
+
// Предохранитель обязан срабатывать в безопасную сторону: ниже предела
|
|
30
|
+
// данные не проверены на ПДн, поэтому наружу уходит маска, а не оригинал.
|
|
31
|
+
if (depth > MAX_DEPTH)
|
|
32
|
+
return "***";
|
|
33
|
+
if (Array.isArray(data)) {
|
|
34
|
+
return data.map((item) => walk(item, depth + 1, kind));
|
|
35
|
+
}
|
|
36
|
+
if (!isPlainObject(data)) {
|
|
37
|
+
return kind === undefined ? data : maskValue(data, kind);
|
|
38
|
+
}
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [key, value] of Object.entries(data)) {
|
|
41
|
+
const own = lookupPiiField(key);
|
|
42
|
+
const inherited = kind !== undefined && WRAPPER_KEYS.has(key.toLowerCase()) ? kind : undefined;
|
|
43
|
+
setOwn(out, key, walk(value, depth + 1, own ?? inherited));
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** Вернуть копию `data`, в которой все известные поля с ПДн замаскированы. */
|
|
48
|
+
export function maskPii(data) {
|
|
49
|
+
return walk(data, 0, undefined);
|
|
50
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Стратегии маскирования одиночных значений. Каждая сохраняет ровно столько
|
|
3
|
+
* контекста, сколько нужно, чтобы человек узнал запись, но не смог по ней
|
|
4
|
+
* идентифицировать субъекта.
|
|
5
|
+
*/
|
|
6
|
+
/** Первая буква + звёздочки по длине остатка. Односимвольное значение — одна звёздочка. */
|
|
7
|
+
function keepFirst(value) {
|
|
8
|
+
if (value.length <= 1)
|
|
9
|
+
return "*";
|
|
10
|
+
return value[0] + "*".repeat(value.length - 1);
|
|
11
|
+
}
|
|
12
|
+
/** Каждое слово маскируется отдельно; лишние пробелы схлопываются. */
|
|
13
|
+
function maskFullName(value) {
|
|
14
|
+
return value.trim().split(/\s+/).map(keepFirst).join(" ");
|
|
15
|
+
}
|
|
16
|
+
/** Логин → первая буква; домен → все метки, кроме TLD. */
|
|
17
|
+
function maskEmail(value) {
|
|
18
|
+
const at = value.lastIndexOf("@");
|
|
19
|
+
if (at <= 0 || at === value.length - 1)
|
|
20
|
+
return keepFirst(value);
|
|
21
|
+
const local = value.slice(0, at);
|
|
22
|
+
const labels = value.slice(at + 1).split(".");
|
|
23
|
+
const domain = labels.length === 1
|
|
24
|
+
? keepFirst(labels[0])
|
|
25
|
+
: labels
|
|
26
|
+
.map((label, i) => (i === labels.length - 1 ? label : keepFirst(label)))
|
|
27
|
+
.join(".");
|
|
28
|
+
return `${keepFirst(local)}@${domain}`;
|
|
29
|
+
}
|
|
30
|
+
/** Ведущий «+» и две последние цифры остаются, всё между ними — звёздочки. */
|
|
31
|
+
function maskPhone(value) {
|
|
32
|
+
const plus = value.startsWith("+");
|
|
33
|
+
const rest = plus ? value.slice(1) : value;
|
|
34
|
+
if (rest.length <= 2)
|
|
35
|
+
return (plus ? "+" : "") + "*".repeat(rest.length || 1);
|
|
36
|
+
const tail = rest.slice(-2);
|
|
37
|
+
return (plus ? "+" : "") + "*".repeat(rest.length - 2) + tail;
|
|
38
|
+
}
|
|
39
|
+
/** Остаётся только населённый пункт — часть до первой запятой. */
|
|
40
|
+
function maskAddress(value) {
|
|
41
|
+
const comma = value.indexOf(",");
|
|
42
|
+
if (comma <= 0)
|
|
43
|
+
return "***";
|
|
44
|
+
return `${value.slice(0, comma).trim()}, ***`;
|
|
45
|
+
}
|
|
46
|
+
/** Огрубление координаты до ~1 км. */
|
|
47
|
+
function maskGeo(value) {
|
|
48
|
+
return Math.round(value * 100) / 100;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Координата в виде строки («55.7558, 37.6173», «55.7558:37.6173»): каждое
|
|
52
|
+
* число огрубляется, разделители сохраняются. Строка без чисел координатой
|
|
53
|
+
* не является, поэтому маскируется целиком — так безопаснее.
|
|
54
|
+
*/
|
|
55
|
+
function maskGeoString(value) {
|
|
56
|
+
if (!/\d/.test(value))
|
|
57
|
+
return "***";
|
|
58
|
+
return value.replace(/-?\d+(?:\.\d+)?/g, (num) => String(maskGeo(Number(num))));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Замаскировать скалярное значение по типу маски. Значения, к которым маска
|
|
62
|
+
* неприменима (не строка и не число для geo), возвращаются без изменений:
|
|
63
|
+
* решение принимается по имени поля, а тип значения может оказаться любым.
|
|
64
|
+
*/
|
|
65
|
+
export function maskValue(value, kind) {
|
|
66
|
+
if (kind === "geo" && typeof value === "number")
|
|
67
|
+
return maskGeo(value);
|
|
68
|
+
if (typeof value !== "string" || value === "")
|
|
69
|
+
return value;
|
|
70
|
+
switch (kind) {
|
|
71
|
+
case "secret":
|
|
72
|
+
case "url":
|
|
73
|
+
return "***";
|
|
74
|
+
case "fullName":
|
|
75
|
+
return maskFullName(value);
|
|
76
|
+
case "email":
|
|
77
|
+
return maskEmail(value);
|
|
78
|
+
case "phone":
|
|
79
|
+
return maskPhone(value);
|
|
80
|
+
case "address":
|
|
81
|
+
return maskAddress(value);
|
|
82
|
+
case "geo":
|
|
83
|
+
return maskGeoString(value);
|
|
84
|
+
case "name":
|
|
85
|
+
case "misc":
|
|
86
|
+
return keepFirst(value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time сборка тонкого индекса всех эндпоинтов из кэша swagger.
|
|
3
|
+
* В индекс попадают только сигнатуры — развёрнутые схемы тела достаёт
|
|
4
|
+
* SchemaProvider по требованию.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { ALL_METHODS } from "../config.js";
|
|
9
|
+
import { resolvePointer } from "./deref.js";
|
|
10
|
+
export function buildIndex(swaggerDir) {
|
|
11
|
+
const services = [];
|
|
12
|
+
const endpoints = [];
|
|
13
|
+
const files = readdirSync(swaggerDir)
|
|
14
|
+
.filter((f) => f.endsWith(".json"))
|
|
15
|
+
.sort();
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
const code = file.replace(/\.json$/, "").toUpperCase();
|
|
18
|
+
const doc = JSON.parse(readFileSync(join(swaggerDir, file), "utf8"));
|
|
19
|
+
services.push({
|
|
20
|
+
code,
|
|
21
|
+
title: doc.info?.title ?? code,
|
|
22
|
+
description: doc.info?.description ?? "",
|
|
23
|
+
});
|
|
24
|
+
for (const [path, pathItem] of Object.entries(doc.paths ?? {})) {
|
|
25
|
+
for (const [rawMethod, op] of Object.entries(pathItem)) {
|
|
26
|
+
const method = rawMethod.toUpperCase();
|
|
27
|
+
if (!ALL_METHODS.includes(method))
|
|
28
|
+
continue; // parameters, options, trace
|
|
29
|
+
endpoints.push(buildEntry(code, method, path, op, doc));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
services.sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));
|
|
34
|
+
endpoints.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
35
|
+
return { services, endpoints };
|
|
36
|
+
}
|
|
37
|
+
function buildEntry(service, method, path, op, doc) {
|
|
38
|
+
const pathParams = [];
|
|
39
|
+
const queryParams = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
for (const raw of (op.parameters ?? [])) {
|
|
42
|
+
const param = raw.$ref ? (resolvePointer(doc, raw.$ref) ?? {}) : raw;
|
|
43
|
+
if (param.in !== "path" && param.in !== "query")
|
|
44
|
+
continue; // header/cookie ставит клиент
|
|
45
|
+
if (seen.has(param.name))
|
|
46
|
+
continue; // swagger иногда дублирует параметр
|
|
47
|
+
seen.add(param.name);
|
|
48
|
+
(param.in === "path" ? pathParams : queryParams).push(param.name);
|
|
49
|
+
}
|
|
50
|
+
const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
|
|
51
|
+
return {
|
|
52
|
+
id: `${service}:${method}:${path}`,
|
|
53
|
+
service,
|
|
54
|
+
method,
|
|
55
|
+
path,
|
|
56
|
+
summary: op.summary || op.description || `${method} ${path}`,
|
|
57
|
+
tags: Array.isArray(op.tags) ? [...op.tags] : [],
|
|
58
|
+
pathParams,
|
|
59
|
+
queryParams,
|
|
60
|
+
hasBody: Boolean(bodySchema),
|
|
61
|
+
bodyRequired: Boolean(bodySchema) && op.requestBody?.required === true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Стабильная сериализация: два прогона дают побайтово одинаковый результат. */
|
|
65
|
+
export function serializeIndex(index) {
|
|
66
|
+
return JSON.stringify(index, null, 2) + "\n";
|
|
67
|
+
}
|